From 57a61b3057bd3cbb0ecf7b2ae9c751514406458c Mon Sep 17 00:00:00 2001 From: tarunmalviya Date: Tue, 25 Aug 2026 10:56:03 +0530 Subject: [PATCH 01/21] DDS: Agent settlement (Agent Tran), Close Party account UI, reports UI, Party-Savings link field --- src/app/accounts/accounts-routing.module.ts | 26 + src/app/core/shell/shell.component.html | 9 +- .../daily-transaction-entry.component.html | 241 +++++ .../daily-transaction-entry.component.scss | 187 ++++ .../daily-transaction-entry.component.ts | 290 ++++++ .../dds-reports/dds-reports.component.html | 33 + .../dds-reports/dds-reports.component.scss | 97 ++ .../dds-reports/dds-reports.component.ts | 73 ++ .../report-viewer.component.html | 94 ++ .../report-viewer.component.scss | 103 +++ .../report-viewer/report-viewer.component.ts | 120 +++ .../party-master/party-master.component.html | 543 +++++++++++ .../party-master/party-master.component.scss | 164 ++++ .../party-master/party-master.component.ts | 527 +++++++++++ .../agent-master/agent-master.component.html | 355 ++++++++ .../agent-master/agent-master.component.scss | 141 +++ .../agent-master/agent-master.component.ts | 381 ++++++++ .../commission-slab-master.component.html | 93 ++ .../commission-slab-master.component.scss | 92 ++ .../commission-slab-master.component.ts | 138 +++ .../dds-batch-processing.component.html | 133 +++ .../dds-batch-processing.component.scss | 145 +++ .../dds-batch-processing.component.ts | 186 ++++ .../organization-routing.module.ts | 18 + .../organization/organization.component.html | 41 + src/app/organization/organization.service.ts | 135 +++ ...aily-deposit-scheme-product.component.html | 131 +++ ...aily-deposit-scheme-product.component.scss | 138 +++ ...-daily-deposit-scheme-product.component.ts | 346 +++++++ ...posit-scheme-products-template.resolver.ts | 35 + ...eme-product-accounting-step.component.html | 121 +++ ...eme-product-accounting-step.component.scss | 303 +++++++ ...cheme-product-accounting-step.component.ts | 423 +++++++++ ...scheme-product-charges-step.component.html | 85 ++ ...scheme-product-charges-step.component.scss | 69 ++ ...t-scheme-product-charges-step.component.ts | 103 +++ ...cheme-product-currency-step.component.html | 89 ++ ...cheme-product-currency-step.component.scss | 102 +++ ...-scheme-product-currency-step.component.ts | 104 +++ ...scheme-product-details-step.component.html | 77 ++ ...scheme-product-details-step.component.scss | 81 ++ ...t-scheme-product-details-step.component.ts | 177 ++++ ...ct-interest-rate-chart-step.component.html | 109 +++ ...ct-interest-rate-chart-step.component.scss | 215 +++++ ...duct-interest-rate-chart-step.component.ts | 717 +++++++++++++++ ...scheme-product-preview-step.component.html | 732 +++++++++++++++ ...scheme-product-preview-step.component.scss | 131 +++ ...t-scheme-product-preview-step.component.ts | 142 +++ ...cheme-product-settings-step.component.html | 123 +++ ...cheme-product-settings-step.component.scss | 164 ++++ ...-scheme-product-settings-step.component.ts | 95 ++ ...t-scheme-product-terms-step.component.html | 192 ++++ ...t-scheme-product-terms-step.component.scss | 151 ++++ ...sit-scheme-product-terms-step.component.ts | 169 ++++ .../daily-deposit-scheme-product.resolver.ts | 37 + ...ily-deposit-scheme-products.component.html | 172 ++++ ...ily-deposit-scheme-products.component.scss | 17 + ...daily-deposit-scheme-products.component.ts | 124 +++ .../daily-deposit-scheme-products.resolver.ts | 35 + ...it-scheme-product-and-template.resolver.ts | 35 + ...aily-deposit-scheme-product.component.html | 202 +++++ ...aily-deposit-scheme-product.component.scss | 138 +++ ...-daily-deposit-scheme-product.component.ts | 305 +++++++ ...eposit-scheme-datatable-tab.component.html | 17 + ...eposit-scheme-datatable-tab.component.scss | 7 + ...-deposit-scheme-datatable-tab.component.ts | 38 + ...-deposit-scheme-general-tab.component.html | 843 ++++++++++++++++++ ...-deposit-scheme-general-tab.component.scss | 528 +++++++++++ ...ly-deposit-scheme-general-tab.component.ts | 121 +++ ...t-scheme-interest-rates-tab.component.html | 68 ++ ...t-scheme-interest-rates-tab.component.scss | 40 + ...sit-scheme-interest-rates-tab.component.ts | 66 ++ ...-deposit-scheme-interest-rates.resolver.ts | 16 + ...aily-deposit-scheme-product.component.html | 39 + ...aily-deposit-scheme-product.component.scss | 22 + ...-daily-deposit-scheme-product.component.ts | 44 + src/app/products/products-routing.module.ts | 91 ++ src/app/products/products.component.html | 19 + 78 files changed, 12711 insertions(+), 2 deletions(-) create mode 100644 src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html create mode 100644 src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss create mode 100644 src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts create mode 100644 src/app/deposits/dds-reports/dds-reports.component.html create mode 100644 src/app/deposits/dds-reports/dds-reports.component.scss create mode 100644 src/app/deposits/dds-reports/dds-reports.component.ts create mode 100644 src/app/deposits/dds-reports/report-viewer/report-viewer.component.html create mode 100644 src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss create mode 100644 src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts create mode 100644 src/app/deposits/party-master/party-master.component.html create mode 100644 src/app/deposits/party-master/party-master.component.scss create mode 100644 src/app/deposits/party-master/party-master.component.ts create mode 100644 src/app/organization/agent-master/agent-master.component.html create mode 100644 src/app/organization/agent-master/agent-master.component.scss create mode 100644 src/app/organization/agent-master/agent-master.component.ts create mode 100644 src/app/organization/commission-slab-master/commission-slab-master.component.html create mode 100644 src/app/organization/commission-slab-master/commission-slab-master.component.scss create mode 100644 src/app/organization/commission-slab-master/commission-slab-master.component.ts create mode 100644 src/app/organization/dds-batch-processing/dds-batch-processing.component.html create mode 100644 src/app/organization/dds-batch-processing/dds-batch-processing.component.scss create mode 100644 src/app/organization/dds-batch-processing/dds-batch-processing.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts create mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts create mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss create mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 3654ae7de..566c9762f 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -11,6 +11,10 @@ import { Routes, RouterModule } from '@angular/router'; import { Route } from '../core/route/route.service'; import { AccountsListComponent } from './accounts-list/accounts-list.component'; +import { PartyMasterComponent } from '../deposits/party-master/party-master.component'; +import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component'; +import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component'; +import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component'; const routes: Routes = [ Route.withShell([ @@ -41,6 +45,28 @@ const routes: Routes = [ title: 'Recurring Deposit Accounts', breadcrumb: 'Recurring Deposit Accounts' } + }, + { + path: 'dds', + component: PartyMasterComponent, + data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' } + }, + { + path: 'dds-collection', + component: DailyTransactionEntryComponent, + data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' } + }, + { + path: 'dds-reports', + data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' }, + children: [ + { path: '', component: DdsReportsComponent }, + { + path: 'view/:reportName', + component: ReportViewerComponent, + data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' } + } + ] } ] } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 023c6c8b3..9a32f5b1f 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1048,14 +1048,16 @@ - +
Agent
-
Reports
+ diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html new file mode 100644 index 000000000..553dbf281 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html @@ -0,0 +1,241 @@ + + +
+
+ +

Daily Transaction Detail

+
+ +
+
+
+
+
Agent Information
+
+
Name
+
{{ agentInfo.name || '—' }}
+
Address
+
{{ agentInfo.address || '—' }}
+
City
+
{{ agentInfo.city || '—' }}
+
A/C
+
{{ agentInfo.accountNo || '—' }}
+
Guarantor
+
{{ agentInfo.guarantor1 || '—' }}
+
Guarantor
+
{{ agentInfo.guarantor2 || '—' }}
+
Curr Bal
+
{{ agentInfo.currentBalance | number: '1.2-2' }}
+
+
+ +
+
Party Information
+

Populated once a party is selected in a collection row below.

+
+
+ +
+ + Agent + + + + + + + Date + + + + + + + Tran. Type + + @for (type of transactionTypes; track type) { + {{ type }} + } + + +
+ + @if (isPartyTran) { +
+
+ +

Today's Collections

+ +
+ + @if (collections.length === 0) { +

No collection rows added yet.

+ } + + @for (row of collections.controls; track row; let i = $index) { +
+ + Party Code + + + + TRTP + + @for (mode of transactionModes; track mode) { + {{ mode }} + } + + + + AC Type + + + + A/C No. + + + + Amount + + + + Cheque No + + + + Token No + + + + Description + + + +
+ } +
+ } + + @if (isAgentTran) { +
+
+ +

Agent Settlement

+
+

+ Cash the agent hands over to the branch, deducted from the agent's collected balance shown above. +

+
+ + Amount + + + + TRTP + + @for (mode of transactionModes; track mode) { + {{ mode }} + } + + + + AC Type + + + + A/C No. + + + + Description + + +
+
+ } + + @if (isWithdrawDeposite) { +
+
+ +

Withdraw Deposite

+
+
+ + Creditor Party Code + + + + Creditor Party Name + + + + Creditor Curr Bal + + + + + Debit Party Code + + + + Debit Party Name + + + + Debit Curr Bal + + + + + Amount + + + + Description + + +
+
+ } + + @if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) { +
+

+ "{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once + the backend distinguishes these transaction types. +

+
+ } + +
+ + + + + + +
+
+
+
diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss new file mode 100644 index 000000000..0b527e5a0 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -0,0 +1,187 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.dte-container { + max-width: 1080px; + margin: 0 auto; + padding: 24px 16px; +} + +.dte-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.dte-back { + color: $primary; +} + +.dte-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.dte-info-panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} + +.dte-info-panel { + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + padding: 12px 16px; + background: rgb(15 118 110 / 4%); + + &__title { + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $primary; + margin-bottom: 8px; + } + + &__hint { + margin: 0; + color: rgb(0 0 0 / 50%); + font-size: 13px; + } + + dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; + } + + dt { + font-weight: 500; + color: rgb(0 0 0 / 60%); + font-size: 13px; + } + + dd { + margin: 0; + font-size: 13px; + } +} + +.dte-controls { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.dte-field { + min-width: 180px; + + &--wide { + grid-column: span 2; + } +} + +.dte-section { + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.dte-add-row { + color: $primary; +} + +.dte-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; +} + +.dte-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.dte-collection-row { + display: grid; + grid-template-columns: repeat(7, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.dte-collection-row .dte-field--wide { + grid-column: span 2; +} + +.dte-remove-row { + color: #b91c1c; + margin-top: 8px; +} + +.dte-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); + flex-wrap: wrap; +} + +@media (width <= 1000px) { + .dte-info-panels { + grid-template-columns: 1fr; + } + + .dte-grid { + grid-template-columns: repeat(2, 1fr); + } + + .dte-collection-row { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts new file mode 100644 index 000000000..0eb3edb83 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -0,0 +1,290 @@ +/** + * 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, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * Daily Transaction Entry component. + * + * The screen a collection agent uses at the end of a day's round: pick a + * transaction type, then batch-enter that day's collections against + * assigned parties (Party Tran), settle collected cash to the branch + * (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw + * Deposite). Modelled directly on the legacy Daily Transaction Detail + * screen this replaces. + * + * Backed by the DDS Party Transaction API (`/dds/partytransactions`) for + * Party Tran, Agent Tran and Withdraw Deposite - the three types this + * screen actually collects fields for; Party Payment / OD Entry / OD + * Payment remain placeholders until the backend distinguishes them too. + * "Retrieve" (a ready-made list of today's assigned parties) isn't wired - + * that needs a dedicated per-agent Party lookup this MVP doesn't have yet. + */ +@Component({ + selector: 'mifosx-daily-transaction-entry', + templateUrl: './daily-transaction-entry.component.html', + styleUrls: ['./daily-transaction-entry.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class DailyTransactionEntryComponent { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + private static readonly TRANSACTION_TYPE_CODES: Record = { + 'Party Tran': 'PARTY_TRAN', + 'Agent Tran': 'AGENT_TRAN', + 'Withdraw Deposite': 'WITHDRAW_DEPOSITE' + }; + + transactionTypes = [ + 'Party Tran', + 'Agent Tran', + 'Party Payment', + 'OD Entry', + 'OD Payment', + 'Withdraw Deposite' + ]; + + transactionModes = [ + 'Cash', + 'Transfer', + 'Clearing' + ]; + + entryForm: UntypedFormGroup = this.formBuilder.group({ + agentId: [ + '', + Validators.required + ], + transactionType: [ + 'Party Tran', + Validators.required + ], + transactionDate: [ + new Date(), + Validators.required + ], + // Withdraw Deposite fields + creditorPartyCode: [''], + creditorPartyName: [ + { + value: '', + disabled: true + } + ], + creditorPartyBalance: [ + { + value: 0, + disabled: true + } + ], + debitPartyCode: [''], + debitPartyName: [ + { + value: '', + disabled: true + } + ], + debitPartyBalance: [ + { + value: 0, + disabled: true + } + ], + withdrawAmount: [0], + withdrawDescription: [''], + // Agent Tran fields + settlementAmount: [0], + settlementMode: ['Cash'], + settlementAccountType: [''], + settlementAccountNo: [''], + settlementDescription: [''], + collections: this.formBuilder.array([]) + }); + + agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; + + get isPartyTran(): boolean { + return this.entryForm.get('transactionType').value === 'Party Tran'; + } + + get isWithdrawDeposite(): boolean { + return this.entryForm.get('transactionType').value === 'Withdraw Deposite'; + } + + get isAgentTran(): boolean { + return this.entryForm.get('transactionType').value === 'Agent Tran'; + } + + get collections(): UntypedFormArray { + return this.entryForm.get('collections') as UntypedFormArray; + } + + private buildCollectionRow(): UntypedFormGroup { + return this.formBuilder.group({ + partyCode: [ + '', + Validators.required + ], + transactionMode: [ + 'Cash', + Validators.required + ], + accountType: [''], + accountNo: [ + '', + Validators.required + ], + amount: [ + 0, + Validators.required + ], + chequeNo: [''], + tokenNo: [''], + description: [''], + partyName: [ + { + value: '', + disabled: true + } + ], + balanceAfterTransaction: [ + { + value: 0, + disabled: true + } + ] + }); + } + + addCollectionRow(): void { + this.collections.push(this.buildCollectionRow()); + } + + removeCollectionRow(index: number): void { + this.collections.removeAt(index); + } + + loadAgent(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return; + } + this.organizationService.getAgent(agentId).subscribe((agent: any) => { + const guarantors = agent.guarantors || []; + this.agentInfo = { + name: agent.agentNameId || '', + address: agent.agentAddress1 || '', + city: agent.agentCity || '', + accountNo: agent.agentSavingAccountNo || '', + guarantor1: guarantors[0]?.guarantorName || '', + guarantor2: guarantors[1]?.guarantorName || '', + currentBalance: agent.agentCurrentBalance || 0 + }; + }); + } + + retrieve(): void { + // No "my assigned parties" read API exists yet — this will pre-fill + // the collections grid with today's assigned parties once available. + } + + viewLedger(): void { + // No ledger view exists yet for this agent. + } + + signature(): void { + // No signature capture/verification flow exists yet. + } + + cancel(): void { + this.entryForm.reset({ + transactionType: 'Party Tran', + transactionDate: new Date() + }); + this.collections.clear(); + } + + exit(): void { + this.router.navigate(['/accounts/savings']); + } + + save(): void { + const raw = this.entryForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType]; + + const payload: any = { + agentId: Number(raw.agentId), + transactionType, + transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat), + dateFormat, + locale + }; + + if (this.isPartyTran) { + payload.collections = (raw.collections || []).map((row: any) => ({ + partyCode: row.partyCode, + transactionMode: row.transactionMode, + accountType: row.accountType || undefined, + accountNo: row.accountNo, + amount: row.amount, + chequeNo: row.chequeNo || undefined, + tokenNo: row.tokenNo || undefined, + description: row.description || undefined + })); + } else if (this.isWithdrawDeposite) { + payload.creditorPartyCode = raw.creditorPartyCode; + payload.debitPartyCode = raw.debitPartyCode; + payload.withdrawAmount = raw.withdrawAmount; + payload.withdrawDescription = raw.withdrawDescription || undefined; + } else if (this.isAgentTran) { + payload.amount = raw.settlementAmount; + payload.transactionMode = raw.settlementMode; + payload.accountType = raw.settlementAccountType || undefined; + payload.accountNo = raw.settlementAccountNo || undefined; + payload.description = raw.settlementDescription || undefined; + } + + this.organizationService.createPartyTransaction(payload).subscribe(() => { + this.collections.clear(); + this.entryForm.patchValue({ + creditorPartyCode: '', + debitPartyCode: '', + withdrawAmount: 0, + withdrawDescription: '', + settlementAmount: 0, + settlementAccountType: '', + settlementAccountNo: '', + settlementDescription: '' + }); + this.loadAgent(); + }); + } +} diff --git a/src/app/deposits/dds-reports/dds-reports.component.html b/src/app/deposits/dds-reports/dds-reports.component.html new file mode 100644 index 000000000..befe2fe1f --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.html @@ -0,0 +1,33 @@ + + +
+
+ +

DDS Reports

+
+ + @for (group of reportGroups; track group.title) { +
+
{{ group.title }}
+
+ @for (report of group.reports; track report) { + +
+ +
+
{{ report }}
+ +
+ } +
+
+ } +
diff --git a/src/app/deposits/dds-reports/dds-reports.component.scss b/src/app/deposits/dds-reports/dds-reports.component.scss new file mode 100644 index 000000000..56830830a --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.scss @@ -0,0 +1,97 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.dr-container { + max-width: 1080px; + margin: 0 auto; + padding: 24px 16px; +} + +.dr-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.dr-back { + color: $primary; +} + +.dr-group { + margin-bottom: 24px; + + &__title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $primary; + font-weight: 700; + margin-bottom: 10px; + } +} + +.dr-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.dr-card { + display: flex; + align-items: center; + gap: 12px; + background: var(--md-sys-color-surface, #fff); + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 8px; + padding: 12px 14px; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 2px 8px rgb(0 0 0 / 10%); + } + + &__icon { + color: $primary; + font-size: 16px; + flex: none; + } + + &__title { + flex: 1; + font-size: 13px; + font-weight: 500; + } + + &__arrow { + color: rgb(0 0 0 / 30%); + font-size: 12px; + } +} + +@media (width <= 900px) { + .dr-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width <= 600px) { + .dr-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/deposits/dds-reports/dds-reports.component.ts b/src/app/deposits/dds-reports/dds-reports.component.ts new file mode 100644 index 000000000..63af55d01 --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.ts @@ -0,0 +1,73 @@ +/** + * 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 } from '@angular/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * DDS Reports hub component. + * + * Lists every DDS report from the legacy Reports menu, grouped the same + * way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each + * links to the shared report viewer, parameterised by report name. + * + * UI only for now: no report actually queries the backend yet — see + * ReportViewerComponent. + */ +@Component({ + selector: 'mifosx-dds-reports', + templateUrl: './dds-reports.component.html', + styleUrls: ['./dds-reports.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class DdsReportsComponent { + reportGroups = [ + { + title: 'As On Date', + reports: [ + 'Supplementary-DDS (As On)', + 'Agent Check List', + 'Party Check List-As On', + 'Party Balance', + 'Agent Wise Balance', + 'Agent Wise Monthly Collection', + 'Commission Register', + 'Collection Summary Agent Wise', + 'Close Party Ac Register', + 'Agent Partywise Daily Collection', + 'Daily Collection Balance', + 'Interest Certificate', + 'Mature Date Wise Report' + ] + }, + { + title: 'Interest', + reports: [ + 'Party Int. Register', + 'Commission Register' + ] + }, + { + title: 'Daily / Balance / Master', + reports: [ + 'Daily Collection Report', + 'Balance Summary', + 'Master Data Export' + ] + }, + { + title: 'Transaction', + reports: ['Transaction Register'] + }, + { + title: 'Statements', + reports: ['Agent Commission Statement'] + } + ]; +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html new file mode 100644 index 000000000..4b7601dc4 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html @@ -0,0 +1,94 @@ + + +
+
+ +

{{ reportName }}

+
+ +
+
+
+ + From Date + + + + + + + To Date + + + + + + + Agent From + + + + + Agent To + + + + + Party From + + + + + Party To + + +
+ +
+ + +
+ + @if (hasRun) { +
+ @if (!available) { +

{{ unavailableMessage }}

+ } @else if (rows.length === 0) { +

No rows matched these filters.

+ } @else { +
+ + + + @for (column of columns; track column) { + + } + + + + @for (row of rows; track row) { + + @for (column of columns; track column) { + + } + + } + +
{{ column }}
{{ row[column] }}
+
+ } +
+ } +
+
+
diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss new file mode 100644 index 000000000..454eb7c33 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss @@ -0,0 +1,103 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.rv-container { + max-width: 1100px; + margin: 0 auto; + padding: 24px 16px; +} + +.rv-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.rv-back { + color: $primary; +} + +.rv-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.rv-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.rv-field { + width: 100%; +} + +.rv-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.rv-results { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.rv-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; + text-align: center; + padding: 24px 0; +} + +.rv-table-scroll { + overflow-x: auto; +} + +.rv-table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 8px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + } +} + +@media (width <= 900px) { + .rv-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts new file mode 100644 index 000000000..2ab8c9985 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts @@ -0,0 +1,120 @@ +/** + * 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, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * Generic DDS report viewer. + * + * One shared screen for all 13+ DDS reports — the report name comes from + * the route and is mapped to a backend report key. Every report in the + * legacy system shared the same basic filter shape (date, agent range, + * party range), so this avoids building a bespoke screen per report - the + * backend returns a generic {columns, rows} shape this renders as one + * table regardless of which report it is. + * + * One legacy report (Close Party Ac Register) has no corresponding data in + * the backend yet - Party accounts have no closed/open status at all. Run + * Report still calls through, and the backend's `available: false` + + * message is shown instead of a table. + */ +@Component({ + selector: 'mifosx-dds-report-viewer', + templateUrl: './report-viewer.component.html', + styleUrls: ['./report-viewer.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class ReportViewerComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private dateUtils = inject(Dates); + + private static readonly REPORT_KEYS: Record = { + 'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS', + 'Agent Check List': 'AGENT_CHECK_LIST', + 'Party Check List-As On': 'PARTY_CHECK_LIST', + 'Party Balance': 'PARTY_BALANCE', + 'Agent Wise Balance': 'AGENT_WISE_BALANCE', + 'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION', + 'Commission Register': 'COMMISSION_REGISTER', + 'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE', + 'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER', + 'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION', + 'Daily Collection Balance': 'DAILY_COLLECTION_REPORT', + 'Interest Certificate': 'INTEREST_CERTIFICATE', + 'Mature Date Wise Report': 'MATURE_DATE_WISE', + 'Party Int. Register': 'PARTY_INTEREST_REGISTER', + 'Daily Collection Report': 'DAILY_COLLECTION_REPORT', + 'Balance Summary': 'BALANCE_SUMMARY', + 'Master Data Export': 'MASTER_DATA_EXPORT', + 'Transaction Register': 'TRANSACTION_REGISTER', + 'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT' + }; + + reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report'; + + hasRun = false; + available = true; + unavailableMessage = ''; + columns: string[] = []; + rows: Array> = []; + + filterForm: UntypedFormGroup = this.formBuilder.group({ + fromDate: [ + '', + Validators.required + ], + toDate: [ + '', + Validators.required + ], + agentFrom: [1], + agentTo: [99999], + partyFrom: [1], + partyTo: [99999999] + }); + + runReport(): void { + const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName]; + if (!reportKey) { + return; + } + const raw = this.filterForm.getRawValue(); + + this.organizationService + .runDdsReport(reportKey, { + fromDate: this.dateUtils.getDate(raw.fromDate), + toDate: this.dateUtils.getDate(raw.toDate), + agentFrom: raw.agentFrom, + agentTo: raw.agentTo, + partyFrom: raw.partyFrom, + partyTo: raw.partyTo + }) + .subscribe((result: any) => { + this.available = result.available; + this.unavailableMessage = result.message || ''; + this.columns = result.columns || []; + this.rows = result.rows || []; + this.hasRun = true; + }); + } + + exit(): void { + this.router.navigate(['../../'], { relativeTo: this.route }); + } +} diff --git a/src/app/deposits/party-master/party-master.component.html b/src/app/deposits/party-master/party-master.component.html new file mode 100644 index 000000000..416b9a6c7 --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.html @@ -0,0 +1,543 @@ + + +
+
+ +

Party Master Entry

+
+ +
+
+ + +
+
+
+ +

Identity

+
+
+ + Client ID + + + + + Sub Type + + @for (type of subTypes; track type) { + {{ type }} + } + + + + + Agent ID + + + + @if (linkedSavingsAccountId) { + + Savings Account + + + } @else { + + Savings Account No (if any) + + + } + + + Party Code + + + + + Last A/c + + + + + Nominal + + Yes + No + + + + + Party Name + + + + + Party Name 1 + + +
+
+ +
+
+ +

Address & Contact

+
+
+ + Address 1 + + + + + Address 2 + + + + + Address 3 + + + + + City + + + + + Pincode + + + + + Gender + + Male + Female + Other + + + + + Telephone + + + + + Mobile No (SMS) + + +
+
+ +
+
+ +

KYC

+
+
+ + Aadhar No + + + + + PAN Card No + + + + + CKYC ID + + + + + Operation + + @for (op of operations; track op) { + {{ op }} + } + + + + + Birth Date + + + + + + + Occupation + + @for (occ of occupations; track occ) { + {{ occ }} + } + + + + + Risk + + @for (r of riskLevels; track r) { + {{ r }} + } + + +
+
+ +
+
+ +

Minor / Guardian

+
+
+ + Adult / Minor + + @for (opt of adultMinorOptions; track opt) { + {{ opt }} + } + + + + + Minor Date + + + + + + + Guardian Client ID + + + + + Min. A/c Opt + + + + + Relation + + +
+
+ +
+
+ +

Scheme Terms

+
+
+ + Opening Date + + + + + + + Month + + + + + Maturity Date + + + + + Reopen Date + + + + + + + Deposit Amt + + + + + Daily Deposit Amt + + + + + Days + + + + + Ledger No + + + + + Mat. Amt + + + + + Interest Rate + + + + + Withdraw Type + + @for (type of withdrawTypes; track type) { + {{ type }} + } + + +
+
+ +
+
+ +

Status & Overdue Flags

+
+
+
+ OD1 Flag +
+
+ OD2 Flag +
+ + + Lien + + Yes + No + + + + + Pay Date + + + + + + + Payment Amount + + + + + Current Bal + + + + + Opening Bal + + + +
+ Is SMS Active +
+
+ Is Email Active +
+ + + Int.Calc + + + + + Interest Date + + + + + + + Sp.Description + + + + + Fr'z CD + + @for (code of freezeCodes; track code) { + {{ code }} + } + + + + + Adv Code + + @for (code of advCodes; track code) { + {{ code }} + } + + +
+
+ + @if (partyForm.get('advCode').value === 'Transfer To Saving') { +
+
+ +

Maturity Payout Routing

+
+
+ + Credit To Branch + + + + + Credit To A/C Type + + + + + Credit To A/C + + +
+
+ } +
+
+ + +
+
+
+ + Nominee + + Yes + No + + + + + Percentage + Sequence + + + +
+ + @if (nominees.length === 0) { +

No nominees added yet.

+ } + + @for (nominee of nominees.controls; track nominee; let i = $index) { +
+ + Nom.Dt + + + + + + Name + + + + Mobile No + + + + Birth Dt + + + + + + Minor + + Yes + No + + + + Relation + + + + Gender + + Male + Female + Other + + + + Guar. Name + + + +
+ } +
+
+
+
+ + @if (isClosed) { +
+ + This account is closed. No further collections or edits are possible. +
+ } + +
+ + + + @if (partyId && !isClosed) { + + } + +
+
+
+
diff --git a/src/app/deposits/party-master/party-master.component.scss b/src/app/deposits/party-master/party-master.component.scss new file mode 100644 index 000000000..baa31be66 --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.scss @@ -0,0 +1,164 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.pm-container { + max-width: 1080px; + margin: 0 auto; + padding: 24px 16px; +} + +.pm-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.pm-back { + color: $primary; +} + +.pm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.pm-tabs { + ::ng-deep .mat-mdc-tab-body-wrapper { + min-height: 300px; + } +} + +.pm-tab-content { + padding: 20px 24px; +} + +.pm-section { + padding: 16px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.pm-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px 16px; + align-items: start; +} + +.pm-field { + width: 100%; + + &--wide { + grid-column: span 2; + } +} + +.pm-checkbox-field { + display: flex; + align-items: center; + height: 56px; +} + +.pm-radio-group { + display: flex; + align-items: center; + gap: 16px; + height: 56px; +} + +.pm-add-nominee { + color: $primary; + height: fit-content; + align-self: center; +} + +.pm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +.pm-nominee-row { + display: grid; + grid-template-columns: repeat(8, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.pm-remove-nominee { + color: #b91c1c; + margin-top: 8px; +} + +.pm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.pm-callout { + display: flex; + align-items: center; + gap: 10px; + margin: 0 24px; + padding: 12px 16px; + background: rgb(185 28 28 / 8%); + border: 1px solid rgb(185 28 28 / 25%); + border-radius: 6px; + color: #b91c1c; + font-size: 0.9rem; +} + +@media (width <= 1000px) { + .pm-grid { + grid-template-columns: repeat(2, 1fr); + } + + .pm-nominee-row { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts new file mode 100644 index 000000000..125525b16 --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.ts @@ -0,0 +1,527 @@ +/** + * 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, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * Party Master Entry component. + * + * The customer's DDS account — linked to an Agent for daily collection, + * carrying its own scheme terms, KYC, two-stage overdue flags, and a + * nominee list. Modelled directly on the legacy Party Master Entry screen + * this replaces. + * + * Backed by its own DDS Party API (`/dds/parties`). The underlying account + * does not yet reuse the Recurring Deposit account engine (see Product + * Setup) — that linkage is a follow-up once the RD-account-specific fields + * this screen doesn't collect (interest/compounding periods, chart + * selection, ...) have a home. Until then the scheme/balance fields here + * are the Party's own. + * + * "Close Account" (command=close) permanently closes the Party once its + * balance is zero - the backend rejects the command otherwise. A closed + * Party can no longer be edited or receive new Party Tran collections. + * + * savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of + * the same client (resolved by account number, since that's what an officer has to hand) - + * clarified with the business (2026-08-24) as "link it if the client already has one, otherwise + * leave it". There is no unlink once linked. + */ +@Component({ + selector: 'mifosx-party-master', + templateUrl: './party-master.component.html', + styleUrls: ['./party-master.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabsModule, + MatCheckbox, + MatRadioModule + ] +}) +export class PartyMasterComponent { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + partyId: number | null = null; + selectedTabIndex = 0; + partyStatus = 'ACTIVE'; + linkedSavingsAccountId: number | null = null; + + get isClosed(): boolean { + return this.partyStatus === 'CLOSED'; + } + + subTypes = [ + 'Daily Deposit Scheme', + 'Masik Dhan Varsha Deposit' + ]; + operations = [ + 'Self', + 'Guardian', + 'Joint' + ]; + occupations = [ + 'Business', + 'Service', + 'Agriculture', + 'Homemaker', + 'Student', + 'Retired', + 'Other' + ]; + riskLevels = [ + 'Low', + 'Medium', + 'High' + ]; + adultMinorOptions = [ + 'Adult', + 'Minor' + ]; + withdrawTypes = [ + 'Cash', + 'Transfer', + 'Clearing' + ]; + freezeCodes = [ + 'Normal', + 'Debit Freeze', + 'Credit Freeze', + 'Total Freeze' + ]; + advCodes = [ + 'None', + 'Transfer To Saving', + 'Renew' + ]; + + partyForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + subType: [ + '', + Validators.required + ], + agentId: [ + '', + Validators.required + ], + savingsAccountNo: [''], + lastAccount: [ + { + value: '', + disabled: true + } + ], + partyCode: [ + { + value: '', + disabled: true + } + ], + nominal: [ + 'No', + Validators.required + ], + aadharNo: [''], + partyName: [ + '', + Validators.required + ], + partyName1: [''], + address1: [ + '', + Validators.required + ], + address2: [''], + address3: [''], + city: [ + '', + Validators.required + ], + pincode: [ + '', + Validators.required + ], + gender: [''], + panCardNo: [''], + telephone: [''], + mobileNoSms: [ + '', + Validators.required + ], + operation: [ + 'Self', + Validators.required + ], + birthDate: [''], + occupation: [''], + risk: [''], + ckycId: [''], + adultMinor: [ + 'Adult', + Validators.required + ], + minorDate: [''], + guardianClientId: [''], + minAccountOpt: [''], + relation: [''], + openingDate: [ + '', + Validators.required + ], + month: [''], + maturityDate: [ + { + value: '', + disabled: true + } + ], + reopenDate: [''], + depositAmount: [0], + dailyDepositAmount: [ + 0, + Validators.required + ], + days: [ + '', + Validators.required + ], + ledgerNo: [ + { + value: '', + disabled: true + } + ], + maturityAmount: [ + { + value: 0, + disabled: true + } + ], + interestRate: [ + { + value: 0, + disabled: true + } + ], + withdrawType: [''], + od1Flag: [ + { + value: false, + disabled: true + } + ], + od2Flag: [ + { + value: false, + disabled: true + } + ], + lien: ['No'], + payDate: [''], + paymentAmount: [0], + currentBalance: [ + { + value: 0, + disabled: true + } + ], + openingBalance: [0], + isSmsActive: [true], + isEmailActive: [false], + interestCalc: [ + { + value: 0, + disabled: true + } + ], + interestDate: [''], + spDescription: [''], + freezeCode: ['Normal'], + advCode: ['None'], + creditToBranch: [''], + creditToAccountType: [''], + creditToAccount: [''], + nomineeApplicable: ['No'], + nomineeSplitMode: ['Percentage'], + nominees: this.formBuilder.array([]) + }); + + get nominees(): UntypedFormArray { + return this.partyForm.get('nominees') as UntypedFormArray; + } + + private buildNomineeGroup(): UntypedFormGroup { + return this.formBuilder.group({ + nominationDate: [ + '', + Validators.required + ], + name: [ + '', + Validators.required + ], + mobileNo: [''], + birthDate: [''], + minor: [ + 'No', + Validators.required + ], + relation: [ + '', + Validators.required + ], + gender: [ + '', + Validators.required + ], + guardianName: [''] + }); + } + + addNominee(): void { + this.nominees.push(this.buildNomineeGroup()); + } + + removeNominee(index: number): void { + this.nominees.removeAt(index); + } + + retrieve(): void { + const clientId = this.partyForm.get('clientId')?.value; + if (!clientId) { + return; + } + this.organizationService.getParties().subscribe((parties: any[]) => { + const match = (parties || []).find((party) => String(party.clientId) === String(clientId)); + if (match) { + this.patchFormFromParty(match); + } + }); + } + + cancel(): void { + this.partyId = null; + this.partyStatus = 'ACTIVE'; + this.linkedSavingsAccountId = null; + this.partyForm.reset(); + this.partyForm.patchValue({ + nominal: 'No', + operation: 'Self', + adultMinor: 'Adult', + lien: 'No', + isSmsActive: true, + isEmailActive: false, + freezeCode: 'Normal', + advCode: 'None', + nomineeApplicable: 'No', + nomineeSplitMode: 'Percentage', + currentBalance: 0, + maturityAmount: 0, + interestRate: 0 + }); + this.nominees.clear(); + } + + exit(): void { + this.router.navigate(['/accounts/savings']); + } + + save(): void { + const raw = this.partyForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined); + + const payload: any = { + clientId: Number(raw.clientId), + agentId: Number(raw.agentId), + savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined, + subType: raw.subType, + lastAccount: raw.lastAccount || undefined, + nominal: raw.nominal === 'Yes', + aadharNo: raw.aadharNo || undefined, + partyName: raw.partyName, + partyName1: raw.partyName1 || undefined, + address1: raw.address1, + address2: raw.address2 || undefined, + address3: raw.address3 || undefined, + city: raw.city, + pincode: raw.pincode, + gender: raw.gender || undefined, + panCardNo: raw.panCardNo || undefined, + telephone: raw.telephone || undefined, + mobileNoSms: raw.mobileNoSms, + operation: raw.operation, + birthDate: asDate(raw.birthDate), + occupation: raw.occupation || undefined, + risk: raw.risk || undefined, + ckycId: raw.ckycId || undefined, + adultMinor: raw.adultMinor, + minorDate: asDate(raw.minorDate), + guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined, + minAccountOpt: raw.minAccountOpt || undefined, + relation: raw.relation || undefined, + openingDate: asDate(raw.openingDate), + month: raw.month || undefined, + reopenDate: asDate(raw.reopenDate), + depositAmount: raw.depositAmount || undefined, + dailyDepositAmount: raw.dailyDepositAmount, + days: raw.days, + withdrawType: raw.withdrawType || undefined, + lien: raw.lien === 'Yes', + payDate: asDate(raw.payDate), + paymentAmount: raw.paymentAmount || undefined, + openingBalance: raw.openingBalance || 0, + isSmsActive: !!raw.isSmsActive, + isEmailActive: !!raw.isEmailActive, + interestDate: asDate(raw.interestDate), + spDescription: raw.spDescription || undefined, + freezeCode: raw.freezeCode || undefined, + advCode: raw.advCode || undefined, + creditToBranch: raw.creditToBranch || undefined, + creditToAccountType: raw.creditToAccountType || undefined, + creditToAccount: raw.creditToAccount || undefined, + nomineeApplicable: raw.nomineeApplicable === 'Yes', + nomineeSplitMode: raw.nomineeSplitMode || undefined, + dateFormat, + locale, + nominees: (raw.nominees || []).map((nominee: any) => ({ + nominationDate: this.dateUtils.getDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo || undefined, + birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined, + minor: nominee.minor === 'Yes', + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName || undefined + })) + }; + + if (this.partyId) { + this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit()); + } else { + this.organizationService.createParty(payload).subscribe(() => this.exit()); + } + } + + closeAccount(): void { + if (!this.partyId) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .closeParty(this.partyId, { + closeDate: this.dateUtils.formatDate(new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe(() => this.exit()); + } + + private patchFormFromParty(party: any): void { + this.partyId = party.id; + this.partyStatus = party.status || 'ACTIVE'; + this.linkedSavingsAccountId = party.savingsAccountId || null; + this.partyForm.patchValue({ + clientId: party.clientId, + subType: party.subType, + agentId: party.agentId, + partyCode: party.partyCode, + lastAccount: party.lastAccount, + nominal: party.nominal ? 'Yes' : 'No', + aadharNo: party.aadharNo, + partyName: party.partyName, + partyName1: party.partyName1, + address1: party.address1, + address2: party.address2, + address3: party.address3, + city: party.city, + pincode: party.pincode, + gender: party.gender, + panCardNo: party.panCardNo, + telephone: party.telephone, + mobileNoSms: party.mobileNoSms, + operation: party.operation, + birthDate: party.birthDate ? new Date(party.birthDate) : null, + occupation: party.occupation, + risk: party.risk, + ckycId: party.ckycId, + adultMinor: party.adultMinor, + minorDate: party.minorDate ? new Date(party.minorDate) : null, + guardianClientId: party.guardianClientId, + minAccountOpt: party.minAccountOpt, + relation: party.relation, + openingDate: party.openingDate ? new Date(party.openingDate) : null, + month: party.month, + maturityDate: party.maturityDate, + reopenDate: party.reopenDate ? new Date(party.reopenDate) : null, + depositAmount: party.depositAmount, + dailyDepositAmount: party.dailyDepositAmount, + days: party.days, + ledgerNo: party.ledgerNo, + maturityAmount: party.maturityAmount, + interestRate: party.interestRate, + withdrawType: party.withdrawType, + od1Flag: party.od1Flag, + od2Flag: party.od2Flag, + lien: party.lien ? 'Yes' : 'No', + payDate: party.payDate ? new Date(party.payDate) : null, + paymentAmount: party.paymentAmount, + currentBalance: party.currentBalance, + openingBalance: party.openingBalance, + isSmsActive: party.isSmsActive, + isEmailActive: party.isEmailActive, + interestCalc: party.interestCalc, + interestDate: party.interestDate ? new Date(party.interestDate) : null, + spDescription: party.spDescription, + freezeCode: party.freezeCode, + advCode: party.advCode, + creditToBranch: party.creditToBranch, + creditToAccountType: party.creditToAccountType, + creditToAccount: party.creditToAccount, + nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No', + nomineeSplitMode: party.nomineeSplitMode + }); + + this.nominees.clear(); + (party.nominees || []).forEach((nominee: any) => { + const group = this.buildNomineeGroup(); + group.patchValue({ + nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null, + name: nominee.name, + mobileNo: nominee.mobileNo, + birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null, + minor: nominee.minor ? 'Yes' : 'No', + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName + }); + this.nominees.push(group); + }); + } +} diff --git a/src/app/organization/agent-master/agent-master.component.html b/src/app/organization/agent-master/agent-master.component.html new file mode 100644 index 000000000..243121fd8 --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.html @@ -0,0 +1,355 @@ + + +
+
+ +

Agent Master Entry

+
+ +
+
+
+
+ +

Agent Details

+
+
+ + Client ID + + + + + Agent Code + + + + + Agent Name ID + + + + + Agent Address (Line 1) + + + + + Agent Address (Line 2) + + + + + Agent City + + + + + Agent PinCode + + + + + Agent Telephone + + + + + Home Branch + + +
+
+ +
+
+ +

Collection & Commission Settings

+
+
+ + Com. Calculation + + Yes + No + + + + + TDS + + Yes + No + + + + + TDS % + + + + + Cur.TDS + + + + + TDS Amt + + + + + Limit + + + + + Machine Rent + + +
+
+ +
+
+ +

Settlement & Security Accounts

+
+
+ + Saving Branch + + + + + Agent Saving A/C Type + + @for (accountType of agentSavingAccountTypes; track accountType) { + {{ accountType }} + } + + + + + Agent Saving A/C No + + + + + Trf Branch + + + + + Trf.Amt A/C Type + + + + + Trf.Amt A/C No + + + + + Agent Security Branch + + + + + Agent Security A/C Type + + @for (accountType of agentSavingAccountTypes; track accountType) { + {{ accountType }} + } + + + + + Agent Security A/C No + + + + + Agent Opening Date + + + + + + + Agent Birth Date + + + + + + + Agent Current Balance + + + + + Agent Opening Balance + + +
+
+ +
+
+ +

Collection Limit History

+ +
+ + @if (limits.length === 0) { +

No limit periods added yet.

+ } + + @for (limitRow of limits.controls; track limitRow; let i = $index) { +
+ + From Date + + + + + + To Date + + + + + + Limit + + + +
+ } +
+ +
+
+ +

Guarantor's Detail

+ +
+ + @if (guarantors.length === 0) { +

No guarantors added yet.

+ } + + @for (guarantor of guarantors.controls; track guarantor; let i = $index) { +
+ + Client ID + + + + Guarantor Name + + + + Address 1 + + + + Address 2 + + + + City + + + + Pin Code + + + + Telephone + + + +
+ } +
+ +
+
+ +

Deductions & Deposits

+
+
+ + Comm + + + + + Other Comm + + + + + Ag.Deposit + + + + + Adv.Festival + + + + + Other Deduction + + +
+
+ +
+ + + + +
+
+
+
diff --git a/src/app/organization/agent-master/agent-master.component.scss b/src/app/organization/agent-master/agent-master.component.scss new file mode 100644 index 000000000..1532696bc --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.scss @@ -0,0 +1,141 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.am-container { + max-width: 1040px; + margin: 0 auto; + padding: 24px 16px; +} + +.am-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.am-back { + color: $primary; +} + +.am-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 8px 24px 24px; +} + +.am-section { + padding: 20px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.am-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.am-field { + width: 100%; + + &--wide { + grid-column: span 2; + } +} + +.am-add-guarantor { + color: $primary; +} + +.am-empty-guarantors { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +.am-guarantor-row { + display: grid; + grid-template-columns: repeat(7, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-limit-row { + display: grid; + grid-template-columns: repeat(3, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-remove-guarantor { + color: #b91c1c; + margin-top: 8px; +} + +.am-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + flex-wrap: wrap; +} + +@media (width <= 900px) { + .am-grid { + grid-template-columns: repeat(2, 1fr); + } + + .am-guarantor-row, + .am-limit-row { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/organization/agent-master/agent-master.component.ts b/src/app/organization/agent-master/agent-master.component.ts new file mode 100644 index 000000000..4b35e05f4 --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.ts @@ -0,0 +1,381 @@ +/** + * 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, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * Agent Master Entry component. + * + * A DDS collection agent — either a person or an institution — registered + * with its own settlement savings account and one or more guarantors. As of + * 2026-08-24 the Agent also does the daily collection work directly (the + * SubAgent tier was removed): it carries the collection-limit history, + * TDS/commission settings and security account SubAgent used to own. + * + * Backed by its own DDS Agent API (`/dds/agents`) - independent of Staff/ + * Client CRUD, per the DDS agent/commission hierarchy being genuinely new + * feature work. + */ +@Component({ + selector: 'mifosx-agent-master', + templateUrl: './agent-master.component.html', + styleUrls: ['./agent-master.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class AgentMasterComponent { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + maxDate = new Date(); + agentId: number | null = null; + + agentSavingAccountTypes = [ + 'Savings Deposit Individual A/C', + 'Savings Deposit Joint A/C', + 'Current Account' + ]; + + agentForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + agentCode: [ + { + value: '', + disabled: true + } + ], + agentNameId: [ + '', + Validators.required + ], + agentAddress1: [ + '', + Validators.required + ], + agentAddress2: [''], + agentCity: [ + '', + Validators.required + ], + agentPinCode: [ + '', + Validators.required + ], + agentTelephone: [''], + agentSavingAccountType: [ + '', + Validators.required + ], + agentSavingAccountNo: [ + '', + Validators.required + ], + agentOpeningDate: [ + '', + Validators.required + ], + agentBirthDate: [ + '', + Validators.required + ], + agentCurrentBalance: [ + { + value: 0, + disabled: true + } + ], + agentOpeningBalance: [ + { + value: 0, + disabled: true + } + ], + homeBranch: [ + '', + Validators.required + ], + tdsApplicable: [ + 'No', + Validators.required + ], + tdsPercent: [0], + currentTds: [ + { + value: 0, + disabled: true + } + ], + tdsAmount: [ + { + value: 0, + disabled: true + } + ], + savingBranch: [ + '', + Validators.required + ], + transferBranch: [''], + transferAmountAccountType: [''], + transferAccountNo: [''], + commissionCalculation: [ + 'Yes', + Validators.required + ], + limit: [0], + machineRent: [0], + agentSecurityBranch: [''], + agentSecurityAccountType: [''], + agentSecurityAccountNo: [''], + commission: [ + { + value: 0, + disabled: true + } + ], + otherCommission: [0], + agentDeposit: [0], + festivalAdvance: [0], + otherDeduction: [0], + limits: this.formBuilder.array([]), + guarantors: this.formBuilder.array([]) + }); + + get limits(): UntypedFormArray { + return this.agentForm.get('limits') as UntypedFormArray; + } + + get guarantors(): UntypedFormArray { + return this.agentForm.get('guarantors') as UntypedFormArray; + } + + private buildLimitGroup(): UntypedFormGroup { + return this.formBuilder.group({ + fromDate: [ + '', + Validators.required + ], + toDate: [ + '', + Validators.required + ], + limit: [ + 0, + Validators.required + ] + }); + } + + private buildGuarantorGroup(): UntypedFormGroup { + return this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + guarantorName: [ + '', + Validators.required + ], + address1: [ + '', + Validators.required + ], + address2: [''], + city: [ + '', + Validators.required + ], + pinCode: [ + '', + Validators.required + ], + telephone: [''] + }); + } + + addLimit(): void { + this.limits.push(this.buildLimitGroup()); + } + + removeLimit(index: number): void { + this.limits.removeAt(index); + } + + addGuarantor(): void { + this.guarantors.push(this.buildGuarantorGroup()); + } + + removeGuarantor(index: number): void { + this.guarantors.removeAt(index); + } + + retrieve(): void { + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + this.organizationService.getAgents().subscribe((agents: any[]) => { + const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId)); + if (match) { + this.patchFormFromAgent(match); + } + }); + } + + cancel(): void { + this.agentId = null; + this.agentForm.reset(); + this.agentForm.patchValue({ + agentCurrentBalance: 0, + agentOpeningBalance: 0, + tdsApplicable: 'No', + commissionCalculation: 'Yes', + currentTds: 0, + tdsAmount: 0, + commission: 0 + }); + this.limits.clear(); + this.guarantors.clear(); + } + + exit(): void { + this.router.navigate(['/organization']); + } + + save(): void { + const raw = this.agentForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + + const payload: any = { + clientId: Number(raw.clientId), + agentNameId: raw.agentNameId, + agentAddress1: raw.agentAddress1, + agentAddress2: raw.agentAddress2 || undefined, + agentCity: raw.agentCity, + agentPinCode: raw.agentPinCode, + agentTelephone: raw.agentTelephone || undefined, + agentSavingAccountType: raw.agentSavingAccountType, + agentSavingAccountNo: raw.agentSavingAccountNo, + agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat), + agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat), + agentOpeningBalance: raw.agentOpeningBalance || 0, + homeBranch: raw.homeBranch, + tdsApplicable: raw.tdsApplicable === 'Yes', + tdsPercent: raw.tdsPercent || undefined, + savingBranch: raw.savingBranch, + transferBranch: raw.transferBranch || undefined, + transferAmountAccountType: raw.transferAmountAccountType || undefined, + transferAccountNo: raw.transferAccountNo || undefined, + commissionCalculation: raw.commissionCalculation === 'Yes', + limit: raw.limit || undefined, + machineRent: raw.machineRent || undefined, + agentSecurityBranch: raw.agentSecurityBranch || undefined, + agentSecurityAccountType: raw.agentSecurityAccountType || undefined, + agentSecurityAccountNo: raw.agentSecurityAccountNo || undefined, + otherCommission: raw.otherCommission || undefined, + agentDeposit: raw.agentDeposit || undefined, + festivalAdvance: raw.festivalAdvance || undefined, + otherDeduction: raw.otherDeduction || undefined, + dateFormat, + locale, + limits: (raw.limits || []).map((limit: any) => ({ + fromDate: this.dateUtils.getDate(limit.fromDate), + toDate: this.dateUtils.getDate(limit.toDate), + limit: limit.limit + })), + guarantors: (raw.guarantors || []).map((guarantor: any) => ({ + clientId: Number(guarantor.clientId), + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2 || undefined, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone || undefined + })) + }; + + if (this.agentId) { + this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit()); + } else { + this.organizationService.createAgent(payload).subscribe(() => this.exit()); + } + } + + private patchFormFromAgent(agent: any): void { + this.agentId = agent.id; + this.agentForm.patchValue({ + clientId: agent.clientId, + agentCode: agent.agentCode, + agentNameId: agent.agentNameId, + agentAddress1: agent.agentAddress1, + agentAddress2: agent.agentAddress2, + agentCity: agent.agentCity, + agentPinCode: agent.agentPinCode, + agentTelephone: agent.agentTelephone, + agentSavingAccountType: agent.agentSavingAccountType, + agentSavingAccountNo: agent.agentSavingAccountNo, + agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null, + agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null, + agentCurrentBalance: agent.agentCurrentBalance, + agentOpeningBalance: agent.agentOpeningBalance, + homeBranch: agent.homeBranch, + tdsApplicable: agent.tdsApplicable ? 'Yes' : 'No', + tdsPercent: agent.tdsPercent, + currentTds: agent.currentTds, + tdsAmount: agent.tdsAmount, + savingBranch: agent.savingBranch, + transferBranch: agent.transferBranch, + transferAmountAccountType: agent.transferAmountAccountType, + transferAccountNo: agent.transferAccountNo, + commissionCalculation: agent.commissionCalculation ? 'Yes' : 'No', + limit: agent.limit, + machineRent: agent.machineRent, + agentSecurityBranch: agent.agentSecurityBranch, + agentSecurityAccountType: agent.agentSecurityAccountType, + agentSecurityAccountNo: agent.agentSecurityAccountNo, + commission: agent.commission, + otherCommission: agent.otherCommission, + agentDeposit: agent.agentDeposit, + festivalAdvance: agent.festivalAdvance, + otherDeduction: agent.otherDeduction + }); + + this.limits.clear(); + (agent.limits || []).forEach((limit: any) => { + const group = this.buildLimitGroup(); + group.patchValue({ + fromDate: limit.fromDate ? new Date(limit.fromDate) : null, + toDate: limit.toDate ? new Date(limit.toDate) : null, + limit: limit.limit + }); + this.limits.push(group); + }); + + this.guarantors.clear(); + (agent.guarantors || []).forEach((guarantor: any) => { + const group = this.buildGuarantorGroup(); + group.patchValue(guarantor); + this.guarantors.push(group); + }); + } +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.html b/src/app/organization/commission-slab-master/commission-slab-master.component.html new file mode 100644 index 000000000..31c5b682b --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.html @@ -0,0 +1,93 @@ + + +
+
+ +

Commission Slab Master

+
+ +
+ + + + + + + + + + + + @for (slab of slabs; track slab; let i = $index) { + + + + + + + + } + + + + + + + + + +
Slab TypeSlab FromSlab ToComm Per
{{ slab.slabType }}{{ slab.slabFrom }}{{ slab.slabTo }}{{ slab.commissionPercent }}% + +
+ + + @for (type of slabTypes; track type) { + {{ type }} + } + + + + + + + + + + + + + + + + +
+ + @if (slabs.length === 0) { +

No commission slabs defined yet — add one using the row above.

+ } + +
+ + +
+
+
diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.scss b/src/app/organization/commission-slab-master/commission-slab-master.component.scss new file mode 100644 index 000000000..76eed8f2a --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.scss @@ -0,0 +1,92 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.csm-container { + max-width: 760px; + margin: 0 auto; + padding: 24px 16px; +} + +.csm-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.csm-back { + color: $primary; +} + +.csm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 16px 24px 24px; +} + +.csm-table { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 6px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: middle; + } +} + +.csm-inline-field { + width: 100%; + + ::ng-deep .mat-mdc-text-field-wrapper { + height: 44px; + } +} + +.csm-row-actions { + text-align: center; + width: 48px; +} + +.csm-new-row { + background: rgb(15 118 110 / 4%); +} + +.csm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 12px 0 0; +} + +.csm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.ts b/src/app/organization/commission-slab-master/commission-slab-master.component.ts new file mode 100644 index 000000000..563a0da46 --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.ts @@ -0,0 +1,138 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * Commission Slab Master component. + * + * Two kinds of slab: ACCOUNT (commission percentage on collection volume) + * and COMMISSION (a senior-agent override on another agent's commission - + * deferred: there is no agent hierarchy modelled yet, see the DDS punch + * list). Modelled directly on the legacy Commission Slab Master screen this + * replaces. + * + * Backed by its own DDS Commission Slab API (`/dds/commissionslabs`) - an + * append-only chart, same as the legacy screen: existing rows load on open, + * newly-added rows post on Save, and there is no edit/delete of a + * previously-saved row. + */ +@Component({ + selector: 'mifosx-commission-slab-master', + templateUrl: './commission-slab-master.component.html', + styleUrls: ['./commission-slab-master.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class CommissionSlabMasterComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + + private static readonly SLAB_TYPE_CODES: Record = { + Account: 'ACCOUNT', + Commission: 'COMMISSION' + }; + private static readonly SLAB_TYPE_LABELS: Record = { + ACCOUNT: 'Account', + COMMISSION: 'Commission' + }; + + slabTypes = [ + 'Account', + 'Commission' + ]; + + displayedColumns: string[] = [ + 'slabType', + 'slabFrom', + 'slabTo', + 'commissionPercent', + 'actions' + ]; + + slabs: Array<{ id?: number; slabType: string; slabFrom: number; slabTo: number; commissionPercent: number }> = []; + + newSlabForm: UntypedFormGroup = this.formBuilder.group({ + slabType: [ + '', + Validators.required + ], + slabFrom: [ + 0, + Validators.required + ], + slabTo: [ + 0, + Validators.required + ], + commissionPercent: [ + 0, + Validators.required + ] + }); + + ngOnInit(): void { + this.loadSlabs(); + } + + private loadSlabs(): void { + this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => { + this.slabs = (slabs || []).map((slab) => ({ + id: slab.id, + slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + })); + }); + } + + addSlab(): void { + if (this.newSlabForm.invalid) { + return; + } + this.slabs.push(this.newSlabForm.getRawValue()); + this.newSlabForm.reset({ + slabType: '', + slabFrom: 0, + slabTo: 0, + commissionPercent: 0 + }); + } + + removeSlab(index: number): void { + this.slabs.splice(index, 1); + } + + exit(): void { + this.router.navigate(['/organization']); + } + + save(): void { + const newSlabs = this.slabs.filter((slab) => !slab.id); + if (newSlabs.length === 0) { + return; + } + + const payload = newSlabs.map((slab) => ({ + slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[slab.slabType] || slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + })); + + this.organizationService.createCommissionSlabs(payload).subscribe(() => this.loadSlabs()); + } +} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html new file mode 100644 index 000000000..be20aba12 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html @@ -0,0 +1,133 @@ + + +
+
+ +

DDS Interest & Commission Batch Processing

+
+ +
+
+
+ + Job Type + + @for (type of jobTypes; track type) { + {{ type }} + } + + + + + Branch + + @for (scope of branchScopes; track scope) { + {{ scope }} + } + + + + @if (isBranchSelected) { +
+ @for (office of offices; track office.id) { + + {{ office.name }} + + } + @if (offices.length === 0) { + Loading branches... + } +
+ } + + + {{ isPostingJob ? 'Posting Date' : 'As-On Date' }} + + + + + + + Agent From + + + + + Agent To + + + + + Party From + + + + + Party To + + +
+ +
+ + + Generate runs a preview only — nothing is applied to any account. Post is a + separate, explicit step that commits the previewed amounts. + +
+ + @if (hasGenerated) { +
+
+ +

Preview

+
+ @if (previewRows.length === 0) { +

+ No calculation preview available yet — this appears once the backend batch-job endpoint for "{{ + batchForm.get('jobType').value + }}" is connected. +

+ } @else { + + + + + + + + + + @for (row of previewRows; track row) { + + + + + + } + +
AgentPartyAmount
{{ row.agent }}{{ row.party }}{{ row.amount | number: '1.2-2' }}
+ } +
+ } + +
+ + + +
+
+
+
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss new file mode 100644 index 000000000..499ff754e --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss @@ -0,0 +1,145 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.bp-container { + max-width: 900px; + margin: 0 auto; + padding: 24px 16px; +} + +.bp-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.bp-back { + color: $primary; +} + +.bp-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.bp-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.bp-field { + width: 100%; + + &--wide { + grid-column: span 3; + } +} + +.bp-branch-checklist { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + align-items: center; + padding: 8px 0; +} + +.bp-callout { + display: flex; + gap: 10px; + align-items: flex-start; + background: rgb(15 118 110 / 6%); + border-left: 3px solid $primary; + border-radius: 0 8px 8px 0; + padding: 12px 16px; + margin: 16px 0; + font-size: 13px; + + i { + color: $primary; + margin-top: 2px; + } +} + +.bp-section { + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + } +} + +.bp-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; +} + +.bp-table { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 8px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + } +} + +.bp-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +@media (width <= 900px) { + .bp-grid { + grid-template-columns: repeat(2, 1fr); + } + + .bp-field--wide { + grid-column: span 2; + } +} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts new file mode 100644 index 000000000..2b6bf5cad --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -0,0 +1,186 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * DDS Interest & Commission Batch Processing component. + * + * Six batch job types, each following the same deliberate two-step + * pattern from the legacy system: Calculate first (a dry-run preview), + * then Post as a separate, explicit confirmation — so a wrong calculation + * is never applied by accident. Modelled on the legacy Interest + * Calculation screen this replaces. + * + * Backed by the DDS Batch Run API (`/dds/batchruns`). Only the three + * "Calculation" job types can actually be generated - the three "Posting" + * entries exist in the dropdown for parity with the legacy menu, but Post + * always applies whatever run was most recently generated in this session, + * regardless of which dropdown entry is currently selected (matching how + * the Post button here is only ever gated on hasGenerated, not on jobType). + */ +@Component({ + selector: 'mifosx-dds-batch-processing', + templateUrl: './dds-batch-processing.component.html', + styleUrls: ['./dds-batch-processing.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class DdsBatchProcessingComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + private static readonly JOB_TYPE_CODES: Record = { + 'Interest Calculation': 'INTEREST_CALCULATION', + 'OD Interest Calculation': 'OD_INTEREST_CALCULATION', + 'Commission Calculation': 'COMMISSION_CALCULATION' + }; + + private currentBatchRunId: number | null = null; + + offices: any[] = []; + selectedOfficeNames = new Set(); + + jobTypes = [ + 'Interest Calculation', + 'Interest Posting', + 'OD Interest Calculation', + 'OD Interest Posting', + 'Commission Calculation', + 'Commission Posting' + ]; + + branchScopes = [ + 'All', + 'Selected' + ]; + + hasGenerated = false; + previewRows: Array<{ agent: string; party: string; amount: number }> = []; + + batchForm: UntypedFormGroup = this.formBuilder.group({ + jobType: [ + 'Interest Calculation', + Validators.required + ], + branchScope: [ + 'All', + Validators.required + ], + jobDate: [ + new Date(), + Validators.required + ], + agentFrom: [ + 1, + Validators.required + ], + agentTo: [ + 99999, + Validators.required + ], + partyFrom: [ + 1, + Validators.required + ], + partyTo: [ + 99999999, + Validators.required + ] + }); + + get isPostingJob(): boolean { + return (this.batchForm.get('jobType').value as string).includes('Posting'); + } + + get isBranchSelected(): boolean { + return this.batchForm.get('branchScope')?.value === 'Selected'; + } + + ngOnInit(): void { + this.organizationService.getOffices().subscribe((offices: any[]) => { + this.offices = offices || []; + }); + } + + toggleOffice(officeName: string): void { + if (this.selectedOfficeNames.has(officeName)) { + this.selectedOfficeNames.delete(officeName); + } else { + this.selectedOfficeNames.add(officeName); + } + } + + generate(): void { + const raw = this.batchForm.getRawValue(); + const jobType = DdsBatchProcessingComponent.JOB_TYPE_CODES[raw.jobType]; + + if (!jobType) { + // A "Posting" entry was selected - there is nothing to calculate for + // it, only to post (see class-level note on how Post is wired). + this.hasGenerated = true; + this.previewRows = []; + return; + } + + const dateFormat = this.settingsService.dateFormat; + const payload: any = { + jobType, + branchScope: raw.branchScope, + jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat), + agentFrom: raw.agentFrom, + agentTo: raw.agentTo, + partyFrom: raw.partyFrom, + partyTo: raw.partyTo, + dateFormat, + locale: this.settingsService.language.code + }; + + if (raw.branchScope === 'Selected') { + payload.officeNames = Array.from(this.selectedOfficeNames); + } + + this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => { + this.currentBatchRunId = result.resourceId; + this.organizationService.getDdsBatchRun(this.currentBatchRunId).subscribe((batchRun: any) => { + this.previewRows = (batchRun.rows || []).map((row: any) => ({ + agent: row.agentCode, + party: row.partyCode || '—', + amount: row.amount + })); + this.hasGenerated = true; + }); + }); + } + + post(): void { + if (!this.currentBatchRunId) { + return; + } + this.organizationService.postDdsBatchRun(this.currentBatchRunId).subscribe(() => { + this.currentBatchRunId = null; + this.hasGenerated = false; + this.previewRows = []; + }); + } + + exit(): void { + this.router.navigate(['/organization']); + } +} diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 58bd89ba6..0ddb76ce3 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -17,6 +17,9 @@ import { Route } from '../core/route/route.service'; import { OrganizationComponent } from './organization.component'; import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/loan-provisioning-criteria.component'; import { OfficesComponent } from './offices/offices.component'; +import { AgentMasterComponent } from './agent-master/agent-master.component'; +import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component'; +import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { EmployeesComponent } from './employees/employees.component'; import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component'; import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component'; @@ -237,6 +240,21 @@ const routes: Routes = [ } ] }, + { + path: 'agent-master', + component: AgentMasterComponent, + data: { title: 'Agent Master', breadcrumb: 'Agent Master' } + }, + { + path: 'commission-slab-master', + component: CommissionSlabMasterComponent, + data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' } + }, + { + path: 'dds-batch-processing', + component: DdsBatchProcessingComponent, + data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' } + }, { path: 'employees', data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index f54ef9507..e13b1e18b 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -90,6 +90,47 @@
+
+ +
+ +
+
+

Agent Master

+

+ Register Daily Deposit Scheme collection agents, their commission/TDS settings, limits and guarantors +

+
+ +
+
+ +
+ +
+ +
+
+

Commission Slab Master

+

Define collection-volume and override commission percentage slabs

+
+ +
+
+ +
+ +
+ +
+
+

DDS Interest & Commission Batch

+

Calculate then post interest, OD interest, and commission for DDS accounts

+
+ +
+
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index a5f84ca38..6aab81df1 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -233,6 +233,141 @@ export class OrganizationService { return this.http.get('/currencies'); } + /** + * @returns {Observable} DDS Agents data + */ + getAgents(): Observable { + return this.http.get('/dds/agents'); + } + + /** + * @param {string | number} agentId Agent ID. + * @returns {Observable} DDS Agent. + */ + getAgent(agentId: string | number): Observable { + return this.http.get(`/dds/agents/${agentId}`); + } + + /** + * @param {any} agent Agent to be created. + * @returns {Observable} + */ + createAgent(agent: any): Observable { + return this.http.post('/dds/agents', agent); + } + + /** + * @param {string | number} agentId Agent ID. + * @param {any} agent Agent changes to apply. + * @returns {Observable} + */ + updateAgent(agentId: string | number, agent: any): Observable { + return this.http.put(`/dds/agents/${agentId}`, agent); + } + + /** + * @returns {Observable} DDS Parties data + */ + getParties(): Observable { + return this.http.get('/dds/parties'); + } + + /** + * @param {string | number} partyId Party ID. + * @returns {Observable} DDS Party. + */ + getParty(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}`); + } + + /** + * @param {any} party Party to be created. + * @returns {Observable} + */ + createParty(party: any): Observable { + return this.http.post('/dds/parties', party); + } + + /** + * @param {string | number} partyId Party ID. + * @param {any} party Party changes to apply. + * @returns {Observable} + */ + updateParty(partyId: string | number, party: any): Observable { + return this.http.put(`/dds/parties/${partyId}`, party); + } + + /** + * @param {string | number} partyId Party ID. + * @param {any} closeCommand { closeDate } + * @returns {Observable} + */ + closeParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=close`, closeCommand); + } + + /** + * @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post. + * @returns {Observable} + */ + createPartyTransaction(transaction: any): Observable { + return this.http.post('/dds/partytransactions', transaction); + } + + /** + * @returns {Observable} DDS Commission Slabs data + */ + getCommissionSlabs(): Observable { + return this.http.get('/dds/commissionslabs'); + } + + /** + * @param {any[]} slabs New commission slab rows to add to the chart. + * @returns {Observable} + */ + createCommissionSlabs(slabs: any[]): Observable { + return this.http.post('/dds/commissionslabs', { slabs }); + } + + /** + * @param {any} batch Batch run filters (jobType, branchScope, jobDate, agentFrom/To, partyFrom/To) to generate a preview for. + * @returns {Observable} + */ + generateDdsBatchRun(batch: any): Observable { + return this.http.post('/dds/batchruns', batch); + } + + /** + * @param {string | number} batchRunId Batch run ID. + * @returns {Observable} Batch run, including its previewed/posted rows. + */ + getDdsBatchRun(batchRunId: string | number): Observable { + return this.http.get(`/dds/batchruns/${batchRunId}`); + } + + /** + * @param {string | number} batchRunId Batch run ID to post - applies exactly the rows it previewed. + * @returns {Observable} + */ + postDdsBatchRun(batchRunId: string | number): Observable { + return this.http.post(`/dds/batchruns/${batchRunId}`, {}); + } + + /** + * @param {string} reportKey One of the backend DDS report keys. + * @param {any} filters fromDate, toDate (ISO strings), agentFrom/To, partyFrom/To. + * @returns {Observable} { reportKey, available, message, columns, rows } + */ + runDdsReport(reportKey: string, filters: any): Observable { + let httpParams = new HttpParams(); + Object.keys(filters || {}).forEach((key) => { + if (filters[key] !== undefined && filters[key] !== null && filters[key] !== '') { + httpParams = httpParams.set(key, filters[key]); + } + }); + return this.http.get(`/dds/reports/${reportKey}`, { params: httpParams }); + } + /** * @param {any[]} currencies * @returns {Observable} Currencies data diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html new file mode 100644 index 000000000..aeeca63ce --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html @@ -0,0 +1,131 @@ + + +
+
+ +

{{ 'labels.heading.Create Daily Deposit Scheme Product' | translate }}

+
+ + + + + + + + + {{ 'labels.inputs.DETAILS' | translate }} + +
+ +
+
+ + + + + CBS Base Chart + +
+ @if (activeBaseVersion) { +
+
+
+

{{ activeBaseVersion.versionLabel }}

+

+ Active CBS base chart + Effective from {{ activeBaseVersion.effectiveFromDate }} +

+
+ Active +
+ + @if (activeBaseVersion.remarks) { +

{{ activeBaseVersion.remarks }}

+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? 'N/A' }}Period Type{{ slab.periodTypeLabel }}Annual Rate (%) + {{ slab.annualInterestRate | number: '1.2-2' }} +
+
+ } @else { +

Loading CBS base chart...

+ } +
+
+
+ +
+ +
+ +
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss new file mode 100644 index 000000000..6f9abbbfd --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss @@ -0,0 +1,138 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.rdp-container { + max-width: 960px; + margin: 0 auto; + padding: 24px 16px; +} + +.rdp-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; + + &__icon { + font-size: 20px; + color: $primary; + } + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.rdp-tabs { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + + ::ng-deep { + .mat-mdc-tab-header { + border-bottom: 1px solid rgb(0 0 0 / 8%); + } + + .mat-mdc-tab { + min-width: 100px; + padding: 0 12px; + height: 52px; + } + + .mat-mdc-tab-body-wrapper { + min-height: 200px; + } + + .mat-mdc-tab-body { + overflow: visible !important; + } + + .mat-mdc-tab-body-content { + overflow: visible !important; + } + } +} + +.rdp-tab-icon { + margin-right: 8px; + font-size: 14px; +} + +.rdp-tab-content { + padding: 24px 16px; +} + +.base-rate-card { + padding: 16px; + border: 1px solid #dbe4f0; + border-radius: 14px; + background: linear-gradient(180deg, #f8fbff 0%, #fff 100%); +} + +.base-rate-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.base-rate-title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.base-rate-meta { + margin: 6px 0 0; + color: #5b6b7f; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.base-rate-badge { + padding: 4px 10px; + border-radius: 999px; + background: #dcfce7; + color: #166534; + font-size: 12px; + font-weight: 700; +} + +.base-rate-remarks { + margin: 0 0 12px; + color: #334155; +} + +.base-rate-table { + width: 100%; + border-collapse: collapse; +} + +.rdp-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__cancel { + color: rgb(0 0 0 / 60%); + } + + &__right { + display: flex; + gap: 12px; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts new file mode 100644 index 000000000..741b1425a --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts @@ -0,0 +1,346 @@ +/** + * 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 { AfterViewInit, Component, ViewChild, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +/** Custom Components — Daily Deposit Scheme's own stepper, kept independent + * of Recurring Deposit Products so future changes to either never cross over. */ +import { DailyDepositSchemeProductDetailsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component'; +import { DailyDepositSchemeProductCurrencyStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component'; +import { DailyDepositSchemeProductTermsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component'; +import { DailyDepositSchemeProductSettingsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component'; +import { DailyDepositSchemeProductChargesStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component'; +import { DailyDepositSchemeProductAccountingStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Accounting } from 'app/core/utils/accounting'; +import { Dates } from 'app/core/utils/dates'; +import { MatTabsModule } from '@angular/material/tabs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-create-daily-deposit-scheme-product', + templateUrl: './create-daily-deposit-scheme-product.component.html', + styleUrls: ['./create-daily-deposit-scheme-product.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabsModule, + DailyDepositSchemeProductDetailsStepComponent, + DailyDepositSchemeProductCurrencyStepComponent, + DailyDepositSchemeProductTermsStepComponent, + DailyDepositSchemeProductSettingsStepComponent, + DailyDepositSchemeProductChargesStepComponent, + DailyDepositSchemeProductAccountingStepComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow + ] +}) +export class CreateDailyDepositSchemeProductComponent implements AfterViewInit { + private route = inject(ActivatedRoute); + private productsService = inject(ProductsService); + private router = inject(Router); + private settingsService = inject(SettingsService); + private accounting = inject(Accounting); + private dateUtils = inject(Dates); + + @ViewChild(DailyDepositSchemeProductDetailsStepComponent, { static: true }) + detailsStep: DailyDepositSchemeProductDetailsStepComponent; + @ViewChild(DailyDepositSchemeProductCurrencyStepComponent, { static: true }) + currencyStep: DailyDepositSchemeProductCurrencyStepComponent; + @ViewChild(DailyDepositSchemeProductTermsStepComponent, { static: true }) + termsStep: DailyDepositSchemeProductTermsStepComponent; + @ViewChild(DailyDepositSchemeProductSettingsStepComponent, { static: true }) + settingsStep: DailyDepositSchemeProductSettingsStepComponent; + @ViewChild(DailyDepositSchemeProductChargesStepComponent, { static: true }) + chargesStep: DailyDepositSchemeProductChargesStepComponent; + @ViewChild(DailyDepositSchemeProductAccountingStepComponent, { static: true }) + accountingStep: DailyDepositSchemeProductAccountingStepComponent; + + productsTemplate: any; + accountingRuleData: string[] = []; + selectedTabIndex = 0; + rdInterestRates: any[] = []; + readonly rdSlabColumns = [ + 'srNo', + 'description', + 'fromPeriod', + 'toPeriod', + 'periodTypeLabel', + 'annualInterestRate' + ]; + + constructor() { + this.route.data.subscribe((data: { dailyDepositSchemeProductsTemplate: any }) => { + this.productsTemplate = data.dailyDepositSchemeProductsTemplate; + }); + this.accountingRuleData = this.accounting.getAccountingRulesForSavings(); + this.productsService.getFdBaseInterestRates().subscribe({ + next: (rates: any[]) => { + this.rdInterestRates = rates || []; + }, + error: () => { + this.rdInterestRates = []; + } + }); + } + + ngAfterViewInit(): void { + // The DDS product screen only shows the legacy Account Type Creation fields (Details tab) - + // currency/terms/settings/charges/accounting still exist underneath (the account-creation + // engine needs them), but they're filled in here with fixed sensible defaults rather than + // asked of the user, since the legacy screen never asked for them either. + const template = this.productsTemplate || {}; + + this.currencyForm.patchValue({ + currencyCode: this.currencyForm.value.currencyCode || 'INR', + digitsAfterDecimal: this.currencyForm.value.digitsAfterDecimal ?? 2 + }); + + const firstOptionId = (options: any[]) => options?.[0]?.id ?? ''; + this.termsForm.patchValue({ + interestCompoundingPeriodType: firstOptionId(template.interestCompoundingPeriodTypeOptions), + interestCalculationDaysInYearType: firstOptionId(template.interestCalculationDaysInYearTypeOptions), + minDepositAmount: this.termsForm.value.minDepositAmount || 1, + maxDepositAmount: this.termsForm.value.maxDepositAmount || 999999999 + }); + + // accountingRule 1 = None, matching the legacy screen (which never asked for GL mappings) - + // this also clears every accounting-tab required-field validator, since those only apply + // under Cash/Accrual accounting. + this.accountingForm.patchValue({ accountingRule: 1 }); + + this.detailsForm.get('autoInterestPost')?.valueChanges.subscribe(() => this.syncAutoInterestPost()); + this.detailsForm.get('autoInterestPostWhen')?.valueChanges.subscribe(() => this.syncAutoInterestPost()); + this.syncAutoInterestPost(); + } + + /** Mirrors the simple "Auto Interest Post" / "Auto Interest Post When" / "Auto Interest Post + * Day" fields onto the (hidden) terms-step fields that the account-creation engine actually + * reads. */ + private syncAutoInterestPost(): void { + const autoPost = this.detailsForm?.value.autoInterestPost ?? true; + const postingPeriodType = this.detailsForm?.value.autoInterestPostWhen; + const postingDay = this.detailsForm?.value.autoInterestPostDay; + this.termsForm?.patchValue({ + autoPost, + interestPostingPeriodType: postingPeriodType || this.termsForm.value.interestPostingPeriodType, + frequencyFixDay: postingDay || this.termsForm.value.frequencyFixDay + }); + } + + get detailsForm() { + return this.detailsStep?.DailyDepositSchemeProductDetailsForm; + } + + get currencyForm() { + return this.currencyStep?.DailyDepositSchemeProductCurrencyForm; + } + + get termsForm() { + return this.termsStep?.DailyDepositSchemeProductTermsForm; + } + + get settingsForm() { + return this.settingsStep?.DailyDepositSchemeProductSettingsForm; + } + + get accountingForm() { + return this.accountingStep?.DailyDepositSchemeProductAccountingForm; + } + + get productFormValid() { + return !!this.detailsForm?.valid; + } + + get activeBaseVersion() { + const chart = + this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ?? + this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ?? + this.rdInterestRates[0]; + if (chart) { + return { + ...chart, + versionLabel: chart.versionLabel?.replace('FD', 'DDS'), + remarks: chart.remarks?.replace('FD', 'DDS') + }; + } + return null; + } + + get activeBaseSlabs() { + // The base chart is borrowed from an FD product purely for its period/amount-range + // *structure* - the account-creation engine needs some chart to exist. Its actual rate is + // never used: every slab's annualInterestRate is overridden with the Details tab's own + // Interest Rate field, so DDS never depends on FD's real interest figures. + const rate = this.detailsForm?.value.interestRate; + return (this.activeBaseVersion?.slabs || []).map((slab: any) => ({ + ...slab, + annualInterestRate: rate ?? slab.annualInterestRate + })); + } + + get product() { + // accountTypeGroup / autoInterestPost / autoInterestPostWhen / autoInterestPostDay are the + // legacy Account Type Creation screen's own fields - not real product API parameters. The + // auto-interest-post trio is already mirrored onto termsStep (autoPost / + // interestPostingPeriodType / frequencyFixDay) via syncAutoInterestPost(); accountTypeGroup + // is folded into the description here instead. + const { + accountTypeGroup, + autoInterestPost, + autoInterestPostWhen, + autoInterestPostDay, + interestRate, + ...detailsPayload + } = this.detailsStep.DailyDepositSchemeProductDetails; + const description = [ + accountTypeGroup, + detailsPayload.description + ] + .filter((part) => !!part) + .join(' - '); + + return { + ...detailsPayload, + description, + ...this.currencyStep.DailyDepositSchemeProductCurrency, + ...this.termsStep.DailyDepositSchemeProductTerms, + ...this.settingsStep.DailyDepositSchemeProductSettings, + ...this.chargesStep.DailyDepositSchemeProductCharges, + ...this.accountingStep.DailyDepositSchemeProductAccounting, + charts: this.productCharts, + rdSlabs: this.activeBaseSlabs + }; + } + + get productCharts() { + const activeBaseVersion = this.activeBaseVersion; + if (!activeBaseVersion?.slabs?.length) { + return []; + } + const dateFormat = this.settingsService.dateFormat; + + return [ + { + name: activeBaseVersion.versionLabel || 'CBS Base Chart', + description: activeBaseVersion.remarks || activeBaseVersion.versionLabel || 'CBS base chart', + fromDate: + this.dateUtils.formatDate(activeBaseVersion.effectiveFromDate, dateFormat) || + activeBaseVersion.effectiveFromDate, + ...(activeBaseVersion.effectiveToDate + ? { + endDate: + this.dateUtils.formatDate(activeBaseVersion.effectiveToDate, dateFormat) || + activeBaseVersion.effectiveToDate + } + : {}), + isPrimaryGroupingByAmount: false, + locale: this.settingsService.language.code, + dateFormat, + chartSlabs: this.activeBaseSlabs.map((slab: any) => { + const chartSlab: any = { + periodType: slab.periodTypeEnum, + fromPeriod: slab.fromPeriod, + toPeriod: slab.toPeriod, + annualInterestRate: slab.annualInterestRate, + description: slab.description || '', + incentives: [] as any[] + }; + + if (slab.amountRangeFrom !== null && slab.amountRangeFrom !== undefined && slab.amountRangeFrom !== '') { + chartSlab.amountRangeFrom = slab.amountRangeFrom; + } + + if (slab.amountRangeTo !== null && slab.amountRangeTo !== undefined && slab.amountRangeTo !== '') { + chartSlab.amountRangeTo = slab.amountRangeTo; + } + + return chartSlab; + }) + } + ] as any[]; + } + + submit() { + const product: any = this.pruneEmptyValues({ + ...this.product, + charts: this.productCharts, + charges: (this.product.charges ?? []).map((charge: any) => ({ id: charge.id })), + locale: this.settingsService.language.code, + productCategory: 'DDS' + }); + if (!product.description) { + product.description = ''; + } + delete product.advancedAccountingRules; + this.productsService.createRecurringDepositProduct(product).subscribe((response: any) => { + this.router.navigate( + [ + '../', + response.resourceId + ], + { relativeTo: this.route } + ); + }); + } + + private pruneEmptyValues(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => this.pruneEmptyValues(item)).filter((item) => item !== undefined) as T; + } + + if (value && typeof value === 'object') { + return Object.entries(value as Record).reduce( + (acc, [ + key, + currentValue + ]) => { + const cleanedValue = this.pruneEmptyValues(currentValue); + if (cleanedValue === undefined) { + return acc; + } + return { + ...acc, + [key]: cleanedValue + }; + }, + {} as Record + ) as T; + } + + if (value === '' || value === null || value === undefined) { + return undefined as T; + } + + return value; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts new file mode 100644 index 000000000..f70da1eda --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts @@ -0,0 +1,35 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ProductsService } from '../../products.service'; + +/** + * Daily Deposit Scheme Products template resolver. + * + * Reuses the Recurring Deposit product template — a Daily Deposit Scheme + * product is created through the same underlying product-creation engine. + */ +@Injectable() +export class DailyDepositSchemeProductsTemplateResolver { + private productsService = inject(ProductsService); + + /** + * Returns the Daily Deposit Scheme products template. + * @returns {Observable} + */ + resolve(): Observable { + return this.productsService.getRecurringDepositProductsTemplate(); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html new file mode 100644 index 000000000..f7fd053a9 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html @@ -0,0 +1,121 @@ + + +
+
+
+ + + +

Accounting

+
+ +
+
+
+

Accounting Rule

+
+
+ Cash +
+
+ + @if (isCashOrAccrualAccounting()) { + @for (section of accountingSectionConfigs; track section.key) { +
+
+

{{ section.title }}

+ +
+
+ @for (row of section.rows; track row.key) { + @if ( + (!row.accrualOnly || isAccrualAccounting()) && (!row.dormancyOnly || isDormancyTrackingEnabled()) + ) { +
+
+ + {{ section.pillLabel }} + + {{ row.label }} +
+ +
+ } + } +
+ +
+ @for ( + otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls; + track $index; + let i = $index + ) { +
+
+
+ + {{ section.pillLabel }} + + +
+ +
+ + GL Code + + @for (glAccount of section.otherAccountOptions; track glAccount.id) { + + {{ '(' + glAccount.glCode + ') ' + glAccount.name }} + + } + + +
+
+ +
+ } +
+
+ } + } +
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss new file mode 100644 index 000000000..bac63a1a2 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss @@ -0,0 +1,303 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-700: #334155; +$gray-900: #202124; + +.rdpa-section { + border: 1px solid $border; + border-radius: 10px; + background: #fff; +} + +.rdpa-section__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } +} + +.rdpa-section__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: #eff6ff; + color: #2563eb; + font-size: 13px; +} + +.rdpa-section__body { + padding: 16px 24px 20px; +} + +.rdpa-group { + & + & { + margin-top: 20px; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 0 0 12px; + } + + h4 { + margin: 0 0 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #94a3b8; + } +} + +.rdpa-add-btn { + border: 0; + background: transparent; + color: #2563eb; + font-size: 12px; + font-weight: 700; + padding: 0; + cursor: pointer; +} + +.rdpa-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.rdpa-list + .rdpa-list { + margin-top: 12px; +} + +.rdpa-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + + @media (width <= 900px) { + flex-direction: column; + align-items: stretch; + } +} + +.rdpa-row:has(mifosx-gl-account-selector) { + align-items: stretch; +} + +.rdpa-row--other { + border-style: dashed; + border-color: #cbd5e1; + background: #f8fafc; + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + align-items: center; + padding: 12px 16px; + + @media (width <= 1100px) { + grid-template-columns: 1fr; + } +} + +.rdpa-other-row-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + + @media (width <= 1100px) { + grid-template-columns: 1fr; + gap: 8px; + } +} + +.rdpa-row__meta { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1 1 auto; +} + +.rdpa-row__meta--other { + min-width: 0; + flex: 1 1 auto; + align-self: stretch; +} + +.rdpa-row__label { + font-size: 14px; + font-weight: 600; + color: $gray-700; +} + +.rdpa-row__input { + flex: 0 0 22rem; + max-width: 22rem; + min-width: 18rem; + + @media (width <= 900px) { + width: 100%; + max-width: 100%; + min-width: 0; + } +} + +.rdpa-row__input :is(mifosx-gl-account-selector) { + width: 100%; + display: block; +} + +.rdpa-row__input :is(mifosx-gl-account-selector) :is(.layout-column) { + width: 100%; + margin-right: 0 !important; +} + +.rdpa-row__input :is(mifosx-gl-account-selector) :is(mat-form-field) { + width: 100%; +} + +.rdpa-row__actions { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.rdpa-row__actions--other { + justify-content: flex-end; + align-self: stretch; + width: 100%; + + @media (width <= 1100px) { + justify-content: flex-start; + } +} + +.rdpa-row__name { + width: 100%; + min-width: 0; + max-width: 24rem; + height: 40px; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0 12px; + font-size: 14px; + background: #fff; + + @media (width <= 900px) { + max-width: 100%; + } +} + +.rdpa-row__name--other-account { + max-width: 100%; + flex: 1 1 auto; + height: 48px; +} + +.rdpa-row__other-field { + width: 100%; + min-width: 0; +} + +.rdpa-row__other-field :is(mat-form-field) { + width: 100%; +} + +.rdpa-row__other-field :is(.mat-mdc-form-field-required-marker) { + display: none; +} + +.rdpa-remove-btn { + border: 0; + background: transparent; + color: #ef4444; + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.rdpa-remove-btn--side { + place-self: center end; + margin-right: 4px; + + @media (width <= 1100px) { + justify-self: start; + margin-right: 0; + } +} + +.rdpa-radio-group { + display: flex; + gap: 24px; + flex-wrap: wrap; +} + +.rdpa-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + + &--green { + background: #dcfce7; + color: #16a34a; + } + + &--amber { + background: #fef3c7; + color: #d97706; + } + + &--blue { + background: #dbeafe; + color: #2563eb; + } +} + +@media (width <= 900px) { + .rdpa-row__meta { + margin-bottom: 4px; + } + + .rdpa-row__input { + flex: 1 1 auto; + max-width: 100%; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts new file mode 100644 index 000000000..09ef9c1d7 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts @@ -0,0 +1,423 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, inject } from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component'; +import { Accounting } from 'app/core/utils/accounting'; +import { GLAccount } from 'app/shared/models/general.model'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income'; + +type AccountingRowConfig = { + key: string; + label: string; + inputLabel: string; + controlName: string; + options: GLAccount[]; + required?: boolean; + accrualOnly?: boolean; + dormancyOnly?: boolean; +}; + +type AccountingSectionConfig = { + key: AccountingSectionKey; + title: string; + pillLabel: string; + pillClass: 'green' | 'amber' | 'blue'; + rows: AccountingRowConfig[]; + otherMappingsControlName: string; + otherAccountOptions: GLAccount[]; +}; + +type OtherGlMappingValue = { + accountName: string; + glAccountId: number | null; +}; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-accounting-step', + templateUrl: './daily-deposit-scheme-product-accounting-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-accounting-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + ReactiveFormsModule, + MatRadioGroup, + MatRadioButton, + GlAccountSelectorComponent + ] +}) +export class DailyDepositSchemeProductAccountingStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private accounting = inject(Accounting); + + @Input() DailyDepositSchemeProductsTemplate: any; + @Input() accountingRuleData: any; + @Input() DailyDepositSchemeProductFormValid: boolean; + @Input() isDormancyTrackingActive: any; + + DailyDepositSchemeProductAccountingForm: UntypedFormGroup; + assetAccountOptions: GLAccount[] = []; + liabilityAccountOptions: GLAccount[] = []; + expenseAccountOptions: GLAccount[] = []; + incomeAccountOptions: GLAccount[] = []; + accountingSectionConfigs: AccountingSectionConfig[] = []; + + constructor() { + this.createDailyDepositSchemeProductAccountingForm(); + this.setConditionalControls(); + } + + ngOnInit() { + const accountingMappings = this.DailyDepositSchemeProductsTemplate?.accountingMappings || {}; + const mappings = { + ...accountingMappings, + otherGlMappingsMap: this.DailyDepositSchemeProductsTemplate?.otherGlMappingsMap + }; + const accountingMappingOptions = this.DailyDepositSchemeProductsTemplate?.accountingMappingOptions || {}; + // Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves + // the product's own current accounting rule when editing - previously this was hardcoded to 2 + // unconditionally, which silently forced every edited product onto Cash-based accounting (and + // its required GL mappings) even for products that were and should remain "None". + const accountingRuleId = this.DailyDepositSchemeProductsTemplate?.accountingRule?.id ?? 2; + + this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || []; + this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; + this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; + this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; + + this.buildAccountingSectionConfigs(); + + this.DailyDepositSchemeProductAccountingForm.patchValue({ accountingRule: accountingRuleId }); + this.syncAccountingControls(accountingRuleId); + this.patchAccountingValues(mappings); + this.setDormancyControl(); + } + + createDailyDepositSchemeProductAccountingForm() { + this.DailyDepositSchemeProductAccountingForm = this.formBuilder.group({ + accountingRule: [2], + savingsReferenceAccountId: [''], + savingsControlAccountId: [''], + transfersInSuspenseAccountId: [''], + interestOnSavingsAccountId: [''], + incomeFromFeeAccountId: [''], + incomeFromPenaltyAccountId: [''], + feesReceivableAccountId: [''], + penaltiesReceivableAccountId: [''], + interestPayableAccountId: [''], + escheatLiabilityId: [''], + assetsOtherGlMappings: this.formBuilder.array([]), + liabilitiesOtherGlMappings: this.formBuilder.array([]), + expensesOtherGlMappings: this.formBuilder.array([]), + incomeOtherGlMappings: this.formBuilder.array([]) + }); + } + + private setConditionalControls() { + this.DailyDepositSchemeProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((rule: number) => { + this.syncAccountingControls(rule); + }); + } + + private patchAccountingValues(mappings: any) { + this.DailyDepositSchemeProductAccountingForm.patchValue({ + savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '', + savingsControlAccountId: mappings.savingsControlAccount?.id || '', + transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '', + interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '', + incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '', + incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '', + feesReceivableAccountId: mappings.feeReceivableAccount?.id || '', + penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', + interestPayableAccountId: mappings.interestPayableAccount?.id || '', + escheatLiabilityId: mappings.escheatLiabilityAccount?.id || '' + }); + + if (this.isCashOrAccrualAccounting()) { + const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings; + this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions); + this.setOtherGlMappings( + 'liabilitiesOtherGlMappings', + glMappings?.liabilities || [], + this.liabilityAccountOptions + ); + this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions); + this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions); + } + } + + private createOtherGlMappingGroup(otherGlMapping: Partial = {}) { + return this.formBuilder.group({ + accountName: [ + otherGlMapping.accountName || '', + Validators.required + ], + glAccountId: [ + otherGlMapping.glAccountId || '', + Validators.required + ] + }); + } + + getOtherGlMappingsArray(controlName: string): UntypedFormArray { + return this.DailyDepositSchemeProductAccountingForm.get(controlName) as UntypedFormArray; + } + + private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) { + const arr = this.getOtherGlMappingsArray(controlName); + arr.clear(); + (otherGlMappings || []).forEach((m) => { + arr.push( + this.createOtherGlMappingGroup({ + accountName: m.accountName, + glAccountId: m.glAccountId || options.find((o) => o.glCode === m.glCode)?.id || null + }) + ); + }); + } + + addOtherGlMapping(controlName: string) { + this.getOtherGlMappingsArray(controlName).push(this.createOtherGlMappingGroup()); + } + + removeOtherGlMapping(controlName: string, index: number) { + this.getOtherGlMappingsArray(controlName).removeAt(index); + } + + getOtherGlMappingControl(controlName: string, index: number, fieldName: string): UntypedFormControl { + return this.getOtherGlMappingsArray(controlName).at(index).get(fieldName) as UntypedFormControl; + } + + private clearOtherGlMappings() { + [ + 'assetsOtherGlMappings', + 'liabilitiesOtherGlMappings', + 'expensesOtherGlMappings', + 'incomeOtherGlMappings' + ].forEach((c) => this.getOtherGlMappingsArray(c).clear()); + } + + private getOtherGlMappings() { + const map = (controlName: string, options: GLAccount[]) => + this.getOtherGlMappingsArray(controlName) + .value.filter((item: OtherGlMappingValue) => !!item.accountName || !!item.glAccountId) + .map((item: OtherGlMappingValue) => ({ + accountName: item.accountName, + glCode: options.find((o) => o.id === item.glAccountId)?.glCode || '' + })) + .filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode); + + const otherGlMappings = { + assets: map('assetsOtherGlMappings', this.assetAccountOptions), + liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), + expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), + income: map('incomeOtherGlMappings', this.incomeAccountOptions) + }; + return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; + } + + private setDormancyControl() { + this.isDormancyTrackingActive?.valueChanges?.subscribe(() => { + this.syncAccountingControls(this.DailyDepositSchemeProductAccountingForm.value.accountingRule); + }); + } + + isDormancyTrackingEnabled(): boolean { + return this.isDormancyTrackingActive?.value ?? this.isDormancyTrackingActive ?? false; + } + + private syncAccountingControls(accountingRule: number) { + const setRequired = (name: string, required: boolean) => { + const ctrl = this.DailyDepositSchemeProductAccountingForm.get(name); + if (!ctrl) return; + if (required) { + ctrl.setValidators(Validators.required); + } else { + ctrl.clearValidators(); + } + ctrl.updateValueAndValidity({ emitEvent: false }); + }; + + const cashOrAccrual = this.accounting.isCashOrAccrualAccountingRuleId(accountingRule); + const accrualOnly = this.accounting.isAccrualAccountingRuleId(accountingRule); + + setRequired('savingsReferenceAccountId', cashOrAccrual); + setRequired('savingsControlAccountId', cashOrAccrual); + setRequired('transfersInSuspenseAccountId', cashOrAccrual); + setRequired('interestOnSavingsAccountId', cashOrAccrual); + setRequired('incomeFromFeeAccountId', cashOrAccrual); + setRequired('incomeFromPenaltyAccountId', cashOrAccrual); + setRequired('feesReceivableAccountId', accrualOnly); + setRequired('penaltiesReceivableAccountId', accrualOnly); + setRequired('interestPayableAccountId', accrualOnly); + setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); + + if (!cashOrAccrual) { + this.clearOtherGlMappings(); + } + } + + buildAccountingSectionConfigs() { + this.accountingSectionConfigs = [ + { + key: 'assets', + title: 'Assets', + pillLabel: 'Asset', + pillClass: 'green', + rows: [ + { + key: 'savingsReferenceAccountId', + label: 'Savings Reference Account', + inputLabel: 'Savings reference account', + controlName: 'savingsReferenceAccountId', + options: this.assetAccountOptions, + required: true + }, + { + key: 'feesReceivableAccountId', + label: 'Fees Receivable Account', + inputLabel: 'Fees receivable account', + controlName: 'feesReceivableAccountId', + options: this.assetAccountOptions, + accrualOnly: true + }, + { + key: 'penaltiesReceivableAccountId', + label: 'Penalties Receivable Account', + inputLabel: 'Penalties receivable account', + controlName: 'penaltiesReceivableAccountId', + options: this.assetAccountOptions, + accrualOnly: true + } + ], + otherMappingsControlName: 'assetsOtherGlMappings', + otherAccountOptions: this.assetAccountOptions + }, + { + key: 'liabilities', + title: 'Liabilities', + pillLabel: 'Liability', + pillClass: 'green', + rows: [ + { + key: 'savingsControlAccountId', + label: 'Savings Control Account', + inputLabel: 'Savings control account', + controlName: 'savingsControlAccountId', + options: this.liabilityAccountOptions, + required: true + }, + { + key: 'transfersInSuspenseAccountId', + label: 'Transfers In Suspense Account', + inputLabel: 'Transfers in suspense account', + controlName: 'transfersInSuspenseAccountId', + options: this.liabilityAccountOptions, + required: true + }, + { + key: 'interestPayableAccountId', + label: 'Interest Payable Account', + inputLabel: 'Interest payable account', + controlName: 'interestPayableAccountId', + options: this.liabilityAccountOptions, + accrualOnly: true + }, + { + key: 'escheatLiabilityId', + label: 'Escheat Liability Account', + inputLabel: 'Escheat liability account', + controlName: 'escheatLiabilityId', + options: this.liabilityAccountOptions, + dormancyOnly: true + } + ], + otherMappingsControlName: 'liabilitiesOtherGlMappings', + otherAccountOptions: this.liabilityAccountOptions + }, + { + key: 'expenses', + title: 'Expenses', + pillLabel: 'Expense', + pillClass: 'amber', + rows: [ + { + key: 'interestOnSavingsAccountId', + label: 'Interest On Savings Account', + inputLabel: 'Interest on savings account', + controlName: 'interestOnSavingsAccountId', + options: this.expenseAccountOptions, + required: true + } + ], + otherMappingsControlName: 'expensesOtherGlMappings', + otherAccountOptions: this.expenseAccountOptions + }, + { + key: 'income', + title: 'Income', + pillLabel: 'Income', + pillClass: 'blue', + rows: [ + { + key: 'incomeFromFeeAccountId', + label: 'Income From Fees', + inputLabel: 'Income from fees', + controlName: 'incomeFromFeeAccountId', + options: this.incomeAccountOptions, + required: true + }, + { + key: 'incomeFromPenaltyAccountId', + label: 'Income From Penalties', + inputLabel: 'Income from penalties', + controlName: 'incomeFromPenaltyAccountId', + options: this.incomeAccountOptions, + required: true + } + ], + otherMappingsControlName: 'incomeOtherGlMappings', + otherAccountOptions: this.incomeAccountOptions + } + ]; + } + + get DailyDepositSchemeProductAccounting() { + const { + assetsOtherGlMappings, + liabilitiesOtherGlMappings, + expensesOtherGlMappings, + incomeOtherGlMappings, + ...accounting + } = this.DailyDepositSchemeProductAccountingForm.value; + const otherGlMappings = this.getOtherGlMappings(); + return { ...accounting, ...(otherGlMappings ? { otherGlMappings } : {}) }; + } + + isCashOrAccrualAccounting(): boolean { + return this.accounting.isCashOrAccrualAccountingRuleId( + this.DailyDepositSchemeProductAccountingForm.value.accountingRule + ); + } + + isAccrualAccounting(): boolean { + return this.accounting.isAccrualAccountingRuleId(this.DailyDepositSchemeProductAccountingForm.value.accountingRule); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html new file mode 100644 index 000000000..b2c701878 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html @@ -0,0 +1,85 @@ + + +
+
+ +

{{ 'labels.heading.Charges' | translate }}

+
+
+
+ + {{ 'labels.inputs.Charge' | translate }} + + @for (charge of chargeData | chargesFilter: chargesDataSource : currencyCode.value; track charge) { + + {{ charge.name }} + + } + + + +
+ + @if (chargesDataSource.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} + {{ charge.name + ', ' + charge.currency.displaySymbol }} + {{ 'labels.inputs.Type' | translate }} + {{ charge.chargeCalculationType.value }} + {{ 'labels.inputs.Amount' | translate }} + {{ charge.amount }} + {{ 'labels.inputs.Collected On' | translate }} + {{ charge.chargeTimeType.value }} + {{ 'labels.inputs.Actions' | translate }} + +
+
+ } +
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss new file mode 100644 index 000000000..661815e4c --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss @@ -0,0 +1,69 @@ +/** + * 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/. + */ + +.rdpch-section { + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + background: #fff; + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: #f8f9fa; + border-bottom: 1px solid #e8eaed; + + i { + font-size: 14px; + color: #0ea5e9; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #202124; + } + } + + &__body { + padding: 16px 24px; + } +} + +.rdpch-add-row { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + + &__select { + flex: 1; + min-width: 200px; + } +} + +.rdpch-add-btn { + height: 36px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; +} + +.rdpch-table-wrapper { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + margin-top: 12px; + + table { + width: 100%; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts new file mode 100644 index 000000000..4db6f9ae9 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts @@ -0,0 +1,103 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, inject } from '@angular/core'; +import { UntypedFormControl } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; + +import { TranslateService } from '@ngx-translate/core'; +import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-charges-step', + templateUrl: './daily-deposit-scheme-product-charges-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-charges-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatIconButton, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + ChargesFilterPipe + ] +}) +export class DailyDepositSchemeProductChargesStepComponent implements OnInit { + dialog = inject(MatDialog); + private translateService = inject(TranslateService); + + @Input() DailyDepositSchemeProductsTemplate: any; + @Input() currencyCode: UntypedFormControl; + + chargeData: any; + + chargesDataSource: {}[]; + displayedColumns: string[] = [ + 'name', + 'chargeCalculationType', + 'amount', + 'chargeTimeType', + 'action' + ]; + + ngOnInit() { + this.chargeData = this.DailyDepositSchemeProductsTemplate.chargeOptions; + if (!(this.DailyDepositSchemeProductsTemplate === undefined) && this.DailyDepositSchemeProductsTemplate.id) { + this.chargesDataSource = this.DailyDepositSchemeProductsTemplate.charges; + } else { + this.chargesDataSource = []; + } + this.currencyCode.valueChanges.subscribe(() => (this.chargesDataSource = [])); + } + + addCharge(charge: any) { + this.chargesDataSource = this.chargesDataSource.concat([charge.value]); + charge.value = ''; + } + + deleteCharge(charge: any) { + const deleteChargeDialogRef = this.dialog.open(DeleteDialogComponent, { + data: { deleteContext: this.translateService.instant('labels.inputs.Charge') + ' ' + charge.name } + }); + deleteChargeDialogRef.afterClosed().subscribe((response: any) => { + if (response.delete) { + this.chargesDataSource.splice(this.chargesDataSource.indexOf(charge), 1); + this.chargesDataSource = this.chargesDataSource.concat([]); + } + }); + } + + get DailyDepositSchemeProductCharges() { + return { + charges: this.chargesDataSource + }; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html new file mode 100644 index 000000000..86af4f7ea --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html @@ -0,0 +1,89 @@ + + +
+
+
+ + Currency & Account Status Configuration +
+
+
+ + {{ 'labels.inputs.Currency' | translate }} + + @for (currency of currencyData; track currency) { + + {{ currency.name }} + + } + + Default: Indian Rupee - auto-set for domestic products + + {{ 'labels.inputs.Currency' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Decimal Places' | translate }} + + @for (places of decimalPlacesData; track places) { + {{ places }} + } + + + {{ 'labels.inputs.Decimal Places' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + +
+ +
+
+

Dormancy Alert

+

Enable automated notifications when an account becomes inactive based on product rules.

+
+ + yes + no + +
+ +
+
+

Nomination Facility

+

Allow account holders to designate individuals to receive funds in the event of account holder's death.

+
+ + yes + no + +
+ +
+
+

Auto Debit Facility

+

Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.

+
+ + yes + no + +
+
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss new file mode 100644 index 000000000..d7a2abd40 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss @@ -0,0 +1,102 @@ +/** + * 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/. + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.rdpc-section { + border: 1px solid $border; + border-radius: 10px; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + span { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.rdpc-body { + background: #fff; +} + +.rdpc-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.rdpc-field { + width: 100%; +} + +.rdpc-alert { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 18px 24px 24px; + border-top: 1px solid $border; + background: #f8fafc; + + @media (width <= 760px) { + flex-direction: column; + align-items: flex-start; + } + + &__content { + h4 { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + + p { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: #64748b; + max-width: 540px; + } + } + + &__choices { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts new file mode 100644 index 000000000..098800d33 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts @@ -0,0 +1,104 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-currency-step', + templateUrl: './daily-deposit-scheme-product-currency-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-currency-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTooltip, + MatRadioGroup, + MatRadioButton + ] +}) +export class DailyDepositSchemeProductCurrencyStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + + @Input() DailyDepositSchemeProductsTemplate: any; + + DailyDepositSchemeProductCurrencyForm: UntypedFormGroup; + + currencyData: any; + decimalPlacesData = [ + 2, + 0, + 3 + ]; + + constructor() { + this.createDailyDepositSchemeProductCurrencyForm(); + } + + ngOnInit() { + this.currencyData = this.DailyDepositSchemeProductsTemplate.currencyOptions.filter( + (c: { code: string }) => c.code === 'INR' + ); + + this.DailyDepositSchemeProductCurrencyForm.patchValue({ + currencyCode: this.DailyDepositSchemeProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '', + digitsAfterDecimal: + this.DailyDepositSchemeProductsTemplate.digitsAfterDecimal ?? + this.DailyDepositSchemeProductsTemplate.currency?.decimalPlaces ?? + 2, + dormancyAlert: this.DailyDepositSchemeProductsTemplate.dormancyAlert ?? false, + isDormancyTrackingActive: this.DailyDepositSchemeProductsTemplate.isDormancyTrackingActive ?? false, + daysToInactive: this.DailyDepositSchemeProductsTemplate.daysToInactive ?? 30, + daysToDormancy: this.DailyDepositSchemeProductsTemplate.daysToDormancy ?? 60, + daysToEscheat: this.DailyDepositSchemeProductsTemplate.daysToEscheat ?? 90, + nominationFacility: this.DailyDepositSchemeProductsTemplate.nominationFacility ?? false, + autoDebitFacility: this.DailyDepositSchemeProductsTemplate.autoDebitFacility ?? false + }); + } + + createDailyDepositSchemeProductCurrencyForm() { + this.DailyDepositSchemeProductCurrencyForm = this.formBuilder.group({ + currencyCode: [ + '', + Validators.required + ], + digitsAfterDecimal: [ + 2, + [ + Validators.required, + Validators.min(0) + ] + ], + dormancyAlert: [false], + isDormancyTrackingActive: [false], + daysToInactive: [30], + daysToDormancy: [60], + daysToEscheat: [90], + nominationFacility: [false], + autoDebitFacility: [false] + }); + } + + get DailyDepositSchemeProductCurrency() { + const formValue = this.DailyDepositSchemeProductCurrencyForm.value; + const result: any = { + currencyCode: formValue.currencyCode, + digitsAfterDecimal: formValue.digitsAfterDecimal, + dormancyAlert: formValue.dormancyAlert, + nominationFacility: formValue.nominationFacility, + autoDebitFacility: formValue.autoDebitFacility + }; + if (formValue.dormancyAlert) { + result.daysToInactive = formValue.daysToInactive; + result.daysToDormancy = formValue.daysToDormancy; + result.daysToEscheat = formValue.daysToEscheat; + } + return result; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html new file mode 100644 index 000000000..2fae4cfe6 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html @@ -0,0 +1,77 @@ + + +
+
+
+ +

Account Type Creation

+
+
+ + Account Type Group + + @if (DailyDepositSchemeProductDetailsForm.controls.accountTypeGroup.hasError('required')) { + Account Type Group is required + } + + + + A/c Type + + @if (DailyDepositSchemeProductDetailsForm.controls.shortName.hasError('required')) { + A/c Type is required + } + + + + A/c Type Description + + @if (DailyDepositSchemeProductDetailsForm.controls.name.hasError('required')) { + A/c Type Description is required + } + + + + Interest Rate (%) + + @if (DailyDepositSchemeProductDetailsForm.controls.interestRate.hasError('required')) { + Interest Rate is required + } + + + + Auto Interest Post + + @for (option of yesNoOptions; track option.value) { + {{ option.label }} + } + + + + @if (DailyDepositSchemeProductDetailsForm.get('autoInterestPost')?.value) { + + Auto Interest Post When + + @for (option of autoInterestPostWhenData; track option.id) { + {{ option.value }} + } + + @if (DailyDepositSchemeProductDetailsForm.controls.autoInterestPostWhen.hasError('required')) { + Auto Interest Post When is required + } + + + + Auto Interest Post Day + + + } +
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss new file mode 100644 index 000000000..ca87b04f4 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss @@ -0,0 +1,81 @@ +/** + * 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/. + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.rdpd-section { + border: 1px solid $border; + border-radius: 10px; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.rdpd-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.rdpd-grid--classification { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 900px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.rdpd-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.rdpd-checkbox-row { + display: flex; + align-items: center; + min-height: 56px; +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts new file mode 100644 index 000000000..65f517f15 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts @@ -0,0 +1,177 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, OnChanges, Input, SimpleChanges, inject, ChangeDetectorRef } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatTooltip } from '@angular/material/tooltip'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { Dates } from 'app/core/utils/dates'; +import { SettingsService } from 'app/settings/settings.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-details-step', + templateUrl: './daily-deposit-scheme-product-details-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-details-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTooltip, + CdkTextareaAutosize + ] +}) +export class DailyDepositSchemeProductDetailsStepComponent implements OnInit, OnChanges { + private formBuilder = inject(UntypedFormBuilder); + private dateUtils = inject(Dates); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); + + @Input() DailyDepositSchemeProductsTemplate: any; + + DailyDepositSchemeProductDetailsForm: UntypedFormGroup; + accountTypeData: string[] = []; + customerSegmentData: string[] = []; + liabilityAccountOptions: any[] = []; + /** "Auto Interest Post When" options - the legacy screen's own field, sourced from the same + * enum the (hidden) interest-posting-period setting uses, so the two always stay in sync. */ + autoInterestPostWhenData: any[] = []; + yesNoOptions: Array<{ value: boolean; label: string }> = [ + { value: true, label: 'Yes' }, + { value: false, label: 'No' } + ]; + + constructor() { + this.createDailyDepositSchemeProductDetailsForm(); + } + + ngOnInit() { + if (this.DailyDepositSchemeProductsTemplate) { + this.applyTemplate(this.DailyDepositSchemeProductsTemplate); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.DailyDepositSchemeProductsTemplate?.currentValue) { + this.applyTemplate(changes.DailyDepositSchemeProductsTemplate.currentValue); + } + } + + createDailyDepositSchemeProductDetailsForm() { + this.DailyDepositSchemeProductDetailsForm = this.formBuilder.group({ + accountTypeGroup: [ + '', + Validators.required + ], + name: [ + '', + Validators.required + ], + shortName: [ + '', + Validators.required + ], + description: [''], + autoInterestPost: [true], + autoInterestPostWhen: [ + '', + Validators.required + ], + autoInterestPostDay: [''], + interestRate: [ + 2, + [ + Validators.required, + Validators.min(0) + ] + ], + accountType: [ + null, + Validators.required + ], + glAccountId: [null], + effectiveTo: [''], + endDate: [''], + introducerRequired: [false], + shareLinkingEnabled: [false] + }); + } + + get minEndDate(): Date | null { + const v = this.DailyDepositSchemeProductDetailsForm.get('effectiveTo')?.value; + if (!v) return null; + const d = new Date(v); + d.setDate(d.getDate() + 1); + return d; + } + + private getOptionNames(options: any[]): string[] { + if (!Array.isArray(options)) { + return []; + } + return options + .map((option) => option?.name ?? option?.value ?? option) + .filter((value) => typeof value === 'string' && value.trim().length > 0); + } + + private parseDate(dateValue: any): Date | null { + if (!dateValue) return null; + if (Array.isArray(dateValue)) { + return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); + } + if (dateValue instanceof Date) { + return dateValue; + } + return new Date(dateValue); + } + + compareById(o1: any, o2: any): boolean { + return o1 != null && o2 != null && Number(o1) === Number(o2); + } + + private applyTemplate(template: any) { + this.accountTypeData = this.getOptionNames(template.accountTypeOptions); + this.liabilityAccountOptions = template.accountingMappingOptions?.liabilityAccountOptions ?? []; + this.autoInterestPostWhenData = template.interestPostingPeriodTypeOptions ?? []; + this.cdr.detectChanges(); + + const isEditing = !!template.id; + this.DailyDepositSchemeProductDetailsForm.patchValue({ + accountTypeGroup: template.accountTypeGroup || 'DDS', + name: isEditing ? template.name : template.name || '', + shortName: isEditing ? template.shortName : template.shortName || '', + description: template.description || '', + autoInterestPost: template.interestPostingPeriodType ? true : (template.autoInterestPost ?? true), + autoInterestPostWhen: template.interestPostingPeriodType?.id ?? this.autoInterestPostWhenData[0]?.id ?? '', + autoInterestPostDay: template.autoInterestPostDay ?? template.frequencyFixDay ?? '', + interestRate: template.nominalAnnualInterestRate ?? 2, + accountType: template.accountType ?? this.accountTypeData[0] ?? null, + glAccountId: template.glAccountId ?? null, + effectiveTo: this.parseDate(template.effectiveTo), + endDate: this.parseDate(template.endDate), + introducerRequired: template.introducerRequired === true || template.introducerRequired === 'Yes', + shareLinkingEnabled: !!template.shareLinkingEnabled + }); + } + + get DailyDepositSchemeProductDetails() { + const formValue = { ...this.DailyDepositSchemeProductDetailsForm.value }; + const dateFormat = this.settingsService.dateFormat; + + if (formValue.effectiveTo instanceof Date) { + formValue.effectiveTo = this.dateUtils.formatDate(formValue.effectiveTo, dateFormat) || ''; + } + if (formValue.endDate instanceof Date) { + formValue.endDate = this.dateUtils.formatDate(formValue.endDate, dateFormat) || ''; + } + + // accountTypeGroup / autoInterestPost / autoInterestPostWhen are the legacy screen's own + // fields - they don't map to a real product API parameter by that name, so they're folded + // into the actual payload fields (description, autoPost, interestPostingPeriodType) by the + // parent component rather than sent through as-is. + return formValue; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html new file mode 100644 index 000000000..7b799e488 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html @@ -0,0 +1,109 @@ + + + +
+
+ +

Bank Interest Rate Chart  Reference / Editable

+ + +
+ + @if (_referenceVersionLabel) { +
+ Version: {{ _referenceVersionLabel }} + @if (_referenceRemarks) { +  | {{ _referenceRemarks }} + } +
+ } + + @if (_editableSlabs.length === 0) { +
+

No interest rate slabs defined. Click Add Slab to add one.

+
+ } + + @if (_editableSlabs.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sr.{{ slab.srNo }}Particular + + From + + To + + Period{{ slab.periodTypeLabel }}Rate (%) + +
+
+ + + } +
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss new file mode 100644 index 000000000..21f95779c --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss @@ -0,0 +1,215 @@ +/** + * 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/. + */ + +.rdpi-section { + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: #f8f9fa; + border-bottom: 1px solid #e8eaed; + + i { + font-size: 14px; + color: #0ea5e9; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #202124; + } + } +} + +.rdpi-spacer { + flex: 1; +} + +.rdpi-content { + padding: 16px 24px; +} + +.rdpi-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; +} + +.rdpi-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.rdpi-checkbox-row { + display: flex; + align-items: center; + padding: 4px 0; +} + +.rdpi-add-btn { + height: 36px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; +} + +.rdpi-chart-block { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + background: #fafbfc; +} + +.rdpi-chart-actions { + display: flex; + justify-content: flex-end; +} + +.rdpi-slab-actions { + display: flex; + justify-content: center; + padding: 8px 0 16px; +} + +.rdpi-empty-msg { + text-align: center; + padding: 16px; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 500; + color: #5f6368; + } +} + +.rdpi-table-wrapper { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + margin-top: 8px; + + table { + width: 100%; + } + + tr.incentives-row { + height: 0; + + > td { + padding: 0; + } + } +} + +.rdpi-incentives { + overflow: hidden; +} + +.rdpi-incentive-card { + border-radius: 0; + padding: 16px; +} + +.rdpi-incentive-title { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: #202124; +} + +.rdpi-incentive-add { + margin-bottom: 12px; +} + +@media (width <= 600px) { + .rdpi-grid { + grid-template-columns: 1fr; + } +} + +/* ── Editable reference-rate table ───────────────────────────── */ +.rdpi-ref-section { + margin-top: 24px; + border-color: #bfdbfe; + background: #f0f7ff; + + .rdpi-section__header { + background: #dbeafe; + color: #1e3a5f; + } +} + +.rdpi-ref-badge { + font-size: 11px; + font-weight: 500; + background: #3b82f6; + color: #fff; + border-radius: 10px; + padding: 2px 8px; + vertical-align: middle; +} + +.rdpi-ref-meta { + padding: 8px 16px; + font-size: 13px; + color: #555; + border-bottom: 1px solid #bfdbfe; +} + +.rdpi-ref-table { + width: 100%; +} + +.rdpi-inline-input { + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 3px 6px; + font-size: 13px; + width: 100%; + background: #fff; + + &:focus { + outline: none; + border-color: #3b82f6; + } + + &--num { + width: 70px; + } + + &--rate { + width: 80px; + } +} + +.rdpi-ref-footer { + padding: 10px 16px; +} + +.rdpi-ref-hint { + font-size: 12px; + color: #64748b; + margin: 0; +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts new file mode 100644 index 000000000..f8ac4d193 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts @@ -0,0 +1,717 @@ +/** + * 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, OnChanges, Input, inject } from '@angular/core'; +import { + UntypedFormGroup, + UntypedFormBuilder, + Validators, + UntypedFormArray, + ReactiveFormsModule +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { animate, state, style, transition, trigger } from '@angular/animations'; + +/** Custom Components */ +import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; +import { DepositProductIncentiveFormDialogComponent } from 'app/products/deposit-product-incentive-form-dialog/deposit-product-incentive-form-dialog.component'; + +/** Dialog Components */ +import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; +import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base'; +import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; + +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { TranslateService } from '@ngx-translate/core'; +import { MatButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { MatDivider } from '@angular/material/divider'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; +import { FindPipe } from '../../../../pipes/find.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-interest-rate-chart-step', + templateUrl: './daily-deposit-scheme-product-interest-rate-chart-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-interest-rate-chart-step.component.scss'], + animations: [ + trigger('expandChartSlab', [ + state('collapsed', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) + ]) + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatDivider, + MatButton, + MatCheckbox, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + FindPipe + ] +}) +export class DailyDepositSchemeProductInterestRateChartStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + dialog = inject(MatDialog); + private dateUtils = inject(Dates); + private settingsService = inject(SettingsService); + private translateService = inject(TranslateService); + + @Input() DailyDepositSchemeProductsTemplate: any; + /** Reference rate chart fetched from m_rd_interest_rate_effective / m_rd_interest_rate_slab_history */ + @Input() set rdInterestRates(rates: any[]) { + if (rates && rates.length > 0) { + // Use the first active version's slabs as the editable working copy + const activeVersion = rates.find((v: any) => v.isActive) ?? rates[0]; + this._referenceVersionLabel = activeVersion.versionLabel; + this._referenceRemarks = activeVersion.remarks; + // Deep-clone slabs so the original is not mutated + this._editableSlabs = (activeVersion.slabs ?? []).map((s: any) => ({ ...s })); + this.ensureSlabRangeRules(this._editableSlabs); + } + } + + /** Slabs the user is editing in the reference-rate table */ + _editableSlabs: any[] = []; + _referenceVersionLabel = ''; + _referenceRemarks = ''; + + /** Columns for the editable reference-rate table */ + readonly refSlabColumns = [ + 'srNo', + 'description', + 'fromPeriod', + 'toPeriod', + 'periodTypeLabel', + 'annualInterestRate' + ]; + + /** Returns the payload to save as a new rate version (called by parent after product save) */ + get modifiedRdInterestRatePayload(): any { + return { + versionLabel: this._referenceVersionLabel || 'Rate Version ' + new Date().toISOString().slice(0, 10), + remarks: this._referenceRemarks, + slabs: this._editableSlabs.map((s: any, idx: number) => ({ + srNo: s.srNo ?? idx + 1, + description: s.description, + periodTypeEnum: s.periodTypeEnum ?? 1, + fromPeriod: s.fromPeriod, + toPeriod: s.toPeriod ?? null, + annualInterestRate: s.annualInterestRate + })) + }; + } + + editSlab(index: number, field: string, value: any) { + const slabs = this._editableSlabs.map((slab: any) => ({ ...slab })); + const current = slabs[index]; + if (!current) { + return; + } + + const nextValue = this.normalizeSlabValue(value); + const oldFrom = this.normalizeSlabValue(current.fromPeriod); + const oldTo = this.normalizeSlabValue(current.toPeriod); + + current[field] = nextValue; + + // Keep the first slab anchored at 0 so the chart always starts from zero. + if (index === 0 && field === 'fromPeriod') { + current.fromPeriod = 0; + } + + if (field === 'fromPeriod' && nextValue === null) { + current.fromPeriod = oldFrom; + } + + if (field === 'fromPeriod') { + const effectiveFrom = this.normalizeSlabValue(current.fromPeriod); + const delta = effectiveFrom - oldFrom; + + if (current.toPeriod !== null && current.toPeriod !== undefined) { + current.toPeriod = this.normalizeSlabValue(current.toPeriod) + delta; + } + + this.shiftFollowingSlabs(slabs, index, delta); + } + + if (field === 'toPeriod') { + if (index < slabs.length - 1 && nextValue === null) { + // Non-final slabs should always have a closing boundary so later slabs can stay contiguous. + current.toPeriod = oldTo; + } else { + const delta = (this.normalizeSlabValue(current.toPeriod) ?? 0) - (oldTo ?? 0); + this.shiftFollowingSlabs(slabs, index, delta); + } + } + + this.ensureSlabRangeRules(slabs); + this._editableSlabs = slabs; + } + + addSlab() { + const slabs = this._editableSlabs.map((slab: any) => ({ ...slab })); + const nextSr = slabs.length + 1; + const lastSlab = slabs[slabs.length - 1]; + const lastFrom = this.normalizeSlabValue(lastSlab?.fromPeriod) ?? 0; + + if (lastSlab && this.normalizeSlabValue(lastSlab.toPeriod) === null) { + // Split the open-ended tail so the new slab can become the new last row. + // This keeps the sequence contiguous while preserving the existing tail start. + lastSlab.toPeriod = lastFrom; + } + + const previousSlab = slabs[slabs.length - 1]; + this._editableSlabs = [ + ...slabs, + { + srNo: nextSr, + description: '', + periodTypeEnum: 1, + periodTypeLabel: 'Days', + fromPeriod: slabs.length === 0 ? 0 : this.nextFromPeriod(previousSlab), + toPeriod: null, + annualInterestRate: null + } + ]; + this.ensureSlabRangeRules(this._editableSlabs); + } + + private normalizeSlabValue(value: any): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; + } + + private nextFromPeriod(previousSlab: any): number { + const previousTo = this.normalizeSlabValue(previousSlab?.toPeriod); + const previousFrom = this.normalizeSlabValue(previousSlab?.fromPeriod) ?? 0; + return previousTo === null ? previousFrom + 1 : previousTo + 1; + } + + private shiftFollowingSlabs(slabs: any[], index: number, delta: number): void { + if (!delta) { + return; + } + + for (let i = index + 1; i < slabs.length; i++) { + const slab = slabs[i]; + const currentFrom = this.normalizeSlabValue(slab.fromPeriod); + const currentTo = this.normalizeSlabValue(slab.toPeriod); + + if (currentFrom !== null) { + slab.fromPeriod = currentFrom + delta; + } + + if (currentTo !== null) { + slab.toPeriod = currentTo + delta; + } + } + } + + private ensureSlabRangeRules(slabs: any[]): void { + if (slabs.length === 0) { + return; + } + + slabs[0].fromPeriod = 0; + + for (let i = 1; i < slabs.length; i++) { + const previous = slabs[i - 1]; + const previousTo = this.normalizeSlabValue(previous.toPeriod); + if (previousTo === null) { + slabs[i].fromPeriod = this.normalizeSlabValue(slabs[i].fromPeriod); + continue; + } + + slabs[i].fromPeriod = previousTo + 1; + } + + // Keep the last slab open-ended so the chart ends with null. + slabs[slabs.length - 1].toPeriod = null; + + // Prevent inverted ranges on intermediate rows. + for (let i = 0; i < slabs.length - 1; i++) { + const fromPeriod = this.normalizeSlabValue(slabs[i].fromPeriod); + const toPeriod = this.normalizeSlabValue(slabs[i].toPeriod); + if (fromPeriod !== null && toPeriod !== null && toPeriod < fromPeriod) { + slabs[i].toPeriod = fromPeriod; + } + } + } + + DailyDepositSchemeProductInterestRateChartForm: UntypedFormGroup; + + periodTypeData: any; + entityTypeData: any; + attributeNameData: any; + conditionTypeData: any; + genderData: any; + clientTypeData: any; + clientClassificationData: any; + incentiveTypeData: any; + + chartSlabsDisplayedColumns: any[] = []; + chartSlabsIncentivesDisplayedColumns: string[] = ['incentives']; + incentivesDisplayedColumns: string[] = [ + 'entityType', + 'attributeName', + 'conditionType', + 'attributeValue', + 'incentiveType', + 'amount', + 'actions' + ]; + + minDate = new Date(2000, 0, 1); + maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + + expandChartSlabIndex: number[] = []; + chartDetailData: any = []; + chartsDetail: any[] = []; + + /** + * @param {FormBuilder} formBuilder Form Builder. + * @param {MatDialog} dialog Dialog reference. + * @param {Dates} dateUtils Date Utils to format date. + * @param {SettingsService} settingsService Settings Service. + */ + + constructor() { + this.createDailyDepositSchemeProductInterestRateChartForm(); + } + + ngOnInit() { + this.periodTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.periodTypes; + this.entityTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.entityTypeOptions; + this.attributeNameData = this.DailyDepositSchemeProductsTemplate.chartTemplate.attributeNameOptions; + this.conditionTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.conditionTypeOptions; + this.genderData = this.DailyDepositSchemeProductsTemplate.chartTemplate.genderOptions; + this.clientTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.clientTypeOptions; + this.clientClassificationData = this.DailyDepositSchemeProductsTemplate.chartTemplate.clientClassificationOptions; + this.incentiveTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.incentiveTypeOptions; + + // assignFormData() intentionally not called — old chart form is removed from UI. + // The DailyDepositSchemeProductInterestRateChartForm is kept only for stepControl + // validity; it remains empty (valid) so the preview tab becomes available. + } + + // Assign form the values for edit Daily Deposit Scheme + assignFormData() { + this.addChart(); + const isChartArray = Array.isArray(this.DailyDepositSchemeProductsTemplate.activeChart); + if (this.DailyDepositSchemeProductsTemplate.activeChart) { + if (!isChartArray) { + this.chartDetailData.push(this.DailyDepositSchemeProductsTemplate.activeChart); + } else { + this.chartDetailData = this.DailyDepositSchemeProductsTemplate.activeChart; + } + } + + // Build the array of Objects from the retrived value + this.getChartsDetailsData(); + + // Iterates for every chart in charts + this.charts.controls.forEach((chartDetailControl: UntypedFormGroup, i: number) => { + if (!this.chartsDetail[i]) { + return; + } + + // Iterate for every chartSlab in chart + this.chartsDetail[i].chartSlabs.forEach((chartSlabDetail: any, j: number) => { + const chartSlabInfo = this.formBuilder.group({ + id: [chartSlabDetail.id], + amountRangeFrom: [chartSlabDetail.amountRangeFrom], + amountRangeTo: [chartSlabDetail.amountRangeTo], + annualInterestRate: [ + chartSlabDetail.annualInterestRate, + Validators.required + ], + description: [ + chartSlabDetail.description, + Validators.required + ], + fromPeriod: [ + chartSlabDetail.fromPeriod, + Validators.required + ], + toPeriod: [chartSlabDetail.toPeriod], + periodType: [ + chartSlabDetail.periodType, + Validators.required + ], + incentives: this.formBuilder.array([]) + }); + const formArray = chartDetailControl.controls['chartSlabs'] as UntypedFormArray; + formArray.push(chartSlabInfo); + + // Iterate for every slab in chartSlab + const chartIncentiveControl = (chartDetailControl.controls['chartSlabs'] as UntypedFormArray).controls[j]; + + // Iterate to input all the incentive for particular chart slab + this.chartsDetail[i].chartSlabs[j].incentives.forEach((chartIncentiveDetail: any) => { + const incentiveInfo = this.formBuilder.group({ + amount: [ + chartIncentiveDetail.amount, + Validators.required + ], + attributeName: [ + chartIncentiveDetail.attributeName, + Validators.required + ], + attribureValue: [ + chartIncentiveDetail.attribureValue, + Validators.required + ], + conditionType: [ + chartIncentiveDetail.conditionType, + Validators.required + ], + entityType: [ + chartIncentiveDetail.entityType, + Validators.required + ], + incentiveType: [ + chartIncentiveDetail.incentiveType, + Validators.required + ] + }); + const newFormArray = (chartIncentiveControl as UntypedFormGroup).controls['incentives'] as UntypedFormArray; + newFormArray.push(incentiveInfo); + }); + }); + }); + } + + getChartsDetailsData() { + this.chartDetailData.forEach((chartData: any) => { + const chart: { + id?: any; + endDate: string | Date; + fromDate: string | Date; + isPrimaryGroupingByAmount: any; + name: any; + chartSlabs: any[]; + } = { + endDate: chartData.endDate ? new Date(chartData.endDate) : '', + fromDate: chartData.fromDate ? new Date(chartData.fromDate) : '', + isPrimaryGroupingByAmount: chartData.isPrimaryGroupingByAmount, + name: chartData.name, + chartSlabs: this.getChartSlabsData(chartData) + }; + if (chartData.id) { + chart['id'] = chartData.id; + } + this.chartsDetail.push(chart); + }); + this.DailyDepositSchemeProductInterestRateChartForm.patchValue({ + charts: this.chartsDetail + }); + } + + getChartSlabsData(chartData: any) { + const chartSlabs: any[] = []; + let chartSlabData: any[] = []; + const isChartSlabArray = Array.isArray(chartData.chartSlabs); + if (!isChartSlabArray) { + chartSlabData.push(chartData.chartSlabs); + } else { + chartSlabData = chartData.chartSlabs; + } + + chartSlabData.forEach((eachChartSlabData: any) => { + const chartSlab: { + id?: any; + periodType: any; + amountRangeFrom: any; + amountRangeTo: any; + annualInterestRate: any; + description: any; + fromPeriod: any; + toPeriod: any; + incentives: any[]; + } = { + periodType: eachChartSlabData.periodType.id, + amountRangeFrom: eachChartSlabData.amountRangeFrom, + amountRangeTo: eachChartSlabData.amountRangeTo, + annualInterestRate: eachChartSlabData.annualInterestRate, + description: eachChartSlabData.description ? eachChartSlabData.description : '', + fromPeriod: eachChartSlabData.fromPeriod, + toPeriod: eachChartSlabData.toPeriod, + incentives: this.getIncentivesData(chartSlabData) + }; + if (eachChartSlabData.id) { + chartSlab['id'] = eachChartSlabData.id; + } + chartSlabs.push(chartSlab); + }); + return chartSlabs; + } + + getIncentivesData(chartSlabData: any) { + const incentives: any[] = []; + let incentiveDatas: any[] = []; + if (chartSlabData.incentives) { + const isChartIncentiveArray = Array.isArray(chartSlabData.incentives); + if (!isChartIncentiveArray) { + incentiveDatas.push(chartSlabData.incentives); + } else { + incentiveDatas = chartSlabData.incentives; + } + incentiveDatas.forEach((incentiveData: any) => { + const incentive = { + amount: incentiveData.amount, + attributeName: incentiveData.attributeName, + attributeValue: incentiveData.attributeValue, + conditionType: incentiveData.conditionType, + entityType: incentiveData.entityType, + incentiveType: incentiveData.incentiveType + }; + incentives.push(incentive); + }); + } + return incentives; + } + + createDailyDepositSchemeProductInterestRateChartForm() { + this.DailyDepositSchemeProductInterestRateChartForm = this.formBuilder.group({ + charts: this.formBuilder.array([]) + }); + } + + get charts(): UntypedFormArray { + return this.DailyDepositSchemeProductInterestRateChartForm.get('charts') as UntypedFormArray; + } + + createChartForm(): UntypedFormGroup { + return this.formBuilder.group({ + id: [null], + name: [''], + description: [''], + fromDate: [ + '', + Validators.required + ], + endDate: [''], + isPrimaryGroupingByAmount: [false], + chartSlabs: this.formBuilder.array([], Validators.required) + }); + } + + addChart() { + this.charts.push(this.createChartForm()); + this.setConditionalControls(this.charts.length - 1); + } + + setConditionalControls(chartIndex: number) { + this.chartSlabsDisplayedColumns[chartIndex] = [ + 'period', + 'amountRange', + 'annualInterestRate', + 'description', + 'actions' + ]; + this.charts + .at(chartIndex) + .get('isPrimaryGroupingByAmount') + .valueChanges.subscribe((isPrimaryGroupingByAmount: boolean) => { + this.chartSlabsDisplayedColumns[chartIndex] = isPrimaryGroupingByAmount ? [ + 'amountRange', + 'period' + ] : [ + 'period', + 'amountRange' + ]; + this.chartSlabsDisplayedColumns[chartIndex].push('annualInterestRate', 'description', 'actions'); + }); + } + + getIncentives(chartSlabs: UntypedFormArray, chartSlabIndex: number): UntypedFormArray { + return chartSlabs.at(chartSlabIndex).get('incentives') as UntypedFormArray; + } + + addChartSlab(chartSlabs: UntypedFormArray) { + const data = { ...this.getData('Slab') }; + const dialogRef = this.dialog.open(FormDialogComponent, { data }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response.data) { + response.data.addControl('incentives', this.formBuilder.array([])); + chartSlabs.push(response.data); + } + }); + } + + addIncentive(incentives: UntypedFormArray) { + const data = { ...this.getData('Incentive'), entityType: this.entityTypeData[0].id }; + const dialogRef = this.dialog.open(DepositProductIncentiveFormDialogComponent, { data }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response.data) { + incentives.push(response.data); + } + }); + } + + editChartSlab(chartSlabs: UntypedFormArray, chartSlabIndex: number) { + const data = { ...this.getData('Slab', chartSlabs.at(chartSlabIndex).value), layout: { addButtonText: 'Edit' } }; + const dialogRef = this.dialog.open(FormDialogComponent, { data }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response.data) { + chartSlabs.at(chartSlabIndex).patchValue(response.data.value); + } + }); + } + + editIncentive(incentives: UntypedFormArray, incentiveIndex: number) { + const data = { + ...this.getData('Incentive', incentives.at(incentiveIndex).value), + layout: { addButtonText: 'Edit' } + }; + const dialogRef = this.dialog.open(DepositProductIncentiveFormDialogComponent, { data }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response.data) { + incentives.at(incentiveIndex).patchValue(response.data.value); + } + }); + } + + getData(formType: string, values?: any) { + switch (formType) { + case 'Slab': + return { + title: this.translateService.instant('labels.inputs.Slab'), + formfields: this.getSlabFormfields(values) + }; + case 'Incentive': + return { values, chartTemplate: this.DailyDepositSchemeProductsTemplate.chartTemplate }; + } + } + + getSlabFormfields(values?: any) { + const formfields: FormfieldBase[] = [ + new SelectBase({ + controlName: 'periodType', + label: this.translateService.instant('labels.inputs.Period Type'), + value: values ? values.periodType : this.periodTypeData[0].id, + options: { label: 'value', value: 'id', data: this.periodTypeData }, + required: true, + order: 1 + }), + new InputBase({ + controlName: 'fromPeriod', + label: this.translateService.instant('labels.inputs.Period From'), + value: values ? values.fromPeriod : undefined, + type: 'number', + required: true, + order: 2, + min: 0 + }), + new InputBase({ + controlName: 'toPeriod', + label: this.translateService.instant('labels.inputs.Period To'), + value: values ? values.toPeriod : undefined, + type: 'number', + order: 3, + min: 0 + }), + new InputBase({ + controlName: 'amountRangeFrom', + label: this.translateService.instant('labels.inputs.Amount Range From'), + value: values ? values.amountRangeFrom : undefined, + type: 'number', + order: 4, + min: 0 + }), + new InputBase({ + controlName: 'amountRangeTo', + label: this.translateService.instant('labels.inputs.Amount Range To'), + value: values ? values.amountRangeTo : undefined, + type: 'number', + order: 5, + min: 0 + }), + new InputBase({ + controlName: 'annualInterestRate', + label: this.translateService.instant('labels.inputs.Interest'), + value: values ? values.annualInterestRate : undefined, + type: 'number', + required: true, + order: 6, + min: 0 + }), + new InputBase({ + controlName: 'description', + label: this.translateService.instant('labels.inputs.Description'), + value: values ? values.description : undefined, + required: true, + order: 7 + }) + ]; + return formfields; + } + + get DailyDepositSchemeProductInterestRateChart() { + // TODO: Update once language and date settings are setup + const dateFormat = 'YYYY-MM-DD'; + const locale = this.settingsService.language.code; + const DailyDepositSchemeProductInterestRateChart = this.DailyDepositSchemeProductInterestRateChartForm.value; + for (const chart of DailyDepositSchemeProductInterestRateChart.charts) { + chart.locale = locale; + chart.dateFormat = 'yyyy-MM-dd'; + if (chart.fromDate instanceof Date) { + chart.fromDate = this.dateUtils.formatDateAsString(chart.fromDate, dateFormat); + } + if (chart.endDate) { + if (chart.endDate instanceof Date) { + chart.endDate = this.dateUtils.formatDateAsString(chart.endDate, dateFormat); + } + } + if (chart.endDate === '') { + delete chart.endDate; + } + if (chart.description === '') { + delete chart.description; + } + if (chart.id === null) { + delete chart.id; + } + } + return { + ...DailyDepositSchemeProductInterestRateChart, + rdSlabs: this._editableSlabs + }; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html new file mode 100644 index 000000000..2227219c8 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html @@ -0,0 +1,732 @@ + + +
+ +

{{ DailyDepositSchemeProduct.name }}

+ + + +

{{ 'labels.inputs.Details' | translate }}

+ + +
+ {{ 'labels.inputs.Short Name' | translate }}: + {{ DailyDepositSchemeProduct.shortName }} +
+ + @if (DailyDepositSchemeProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ DailyDepositSchemeProduct.description }} +
+ } + + + @if ( + DailyDepositSchemeProduct.accountType || + DailyDepositSchemeProduct.customerSegment || + DailyDepositSchemeProduct.glAccountId || + DailyDepositSchemeProduct.effectiveTo || + DailyDepositSchemeProduct.introducerRequired !== undefined || + DailyDepositSchemeProduct.shareLinkingEnabled !== undefined + ) { +

Product Classification

+ + + @if (DailyDepositSchemeProduct.accountType) { +
+ Account Type: + {{ DailyDepositSchemeProduct.accountType }} +
+ } + + @if (DailyDepositSchemeProduct.glAccountId) { +
+ GL Account (Liability): + {{ getGlAccountName(DailyDepositSchemeProduct.glAccountId) }} +
+ } + + @if (DailyDepositSchemeProduct.effectiveTo) { +
+ Effective To: + {{ DailyDepositSchemeProduct.effectiveTo | dateFormat }} +
+ } + + @if (DailyDepositSchemeProduct.endDate) { +
+ End Date: + {{ DailyDepositSchemeProduct.endDate | dateFormat }} +
+ } + + @if (DailyDepositSchemeProduct.introducerRequired !== undefined) { +
+ Introducer Required: + {{ + DailyDepositSchemeProduct.introducerRequired === true || + DailyDepositSchemeProduct.introducerRequired === 'Yes' + ? 'Yes' + : 'No' + }} +
+ } + + @if (DailyDepositSchemeProduct.shareLinkingEnabled !== undefined) { +
+ Deposit Account Link: + {{ DailyDepositSchemeProduct.shareLinkingEnabled | yesNo }} +
+ } + } + + +

{{ 'labels.inputs.Currency' | translate }}

+ + +
+ {{ 'labels.inputs.Currency' | translate }}: + {{ + DailyDepositSchemeProduct.currencyCode + | find: DailyDepositSchemeProductsTemplate.currencyOptions : 'code' : 'name' + }} +
+ +
+ {{ 'labels.inputs.Decimal Places' | translate }}: + {{ DailyDepositSchemeProduct.digitsAfterDecimal }} +
+ + @if (DailyDepositSchemeProduct.dormancyAlert !== undefined) { +
+ Dormancy Alert: + {{ DailyDepositSchemeProduct.dormancyAlert | yesNo }} +
+ } + + @if (DailyDepositSchemeProduct.nominationFacility !== undefined) { +
+ Nomination Facility: + {{ DailyDepositSchemeProduct.nominationFacility | yesNo }} +
+ } + + @if (DailyDepositSchemeProduct.autoDebitFacility !== undefined) { +
+ Auto Debit Facility: + {{ DailyDepositSchemeProduct.autoDebitFacility | yesNo }} +
+ } + + @if ( + DailyDepositSchemeProduct.minDepositAmount !== undefined || DailyDepositSchemeProduct.maxDepositAmount !== undefined + ) { + +

Deposit Amount

+ + + @if (DailyDepositSchemeProduct.minDepositAmount !== undefined) { +
+ Minimum: + {{ DailyDepositSchemeProduct.minDepositAmount }} +
+ } + + @if (DailyDepositSchemeProduct.maxDepositAmount !== undefined) { +
+ Maximum: + {{ DailyDepositSchemeProduct.maxDepositAmount }} +
+ } + } + + +

{{ 'labels.inputs.Terms' | translate }}

+ + +
+ {{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%): + {{ DailyDepositSchemeProduct.nominalAnnualInterestRate | formatNumber }} % +
+ +
+ {{ 'labels.inputs.Interest Compounding Period' | translate }}: + {{ + DailyDepositSchemeProduct.interestCompoundingPeriodType + | find: DailyDepositSchemeProductsTemplate.interestCompoundingPeriodTypeOptions : 'id' : 'value' + }} +
+ +
+ {{ 'labels.inputs.Interest Posting Period' | translate }}: + {{ + DailyDepositSchemeProduct.interestPostingPeriodType + | find: DailyDepositSchemeProductsTemplate.interestPostingPeriodTypeOptions : 'id' : 'value' + }} +
+ + @if (DailyDepositSchemeProduct.interestCalculateSelector) { +
+ {{ 'labels.inputs.Interest Calculation Type' | translate }}: + {{ + DailyDepositSchemeProduct.interestCalculateSelector + | find: DailyDepositSchemeProductsTemplate.interestCalculationTypeOption : 'id' : 'value' + }} +
+ } + +
+ {{ 'labels.inputs.Interest Calculated using' | translate }}: + {{ + DailyDepositSchemeProduct.interestCalculationType + | find: DailyDepositSchemeProductsTemplate.interestCalculationTypeOptions : 'id' : 'value' + | translateKey: 'catalogs' + }} +
+ +
+ {{ 'labels.inputs.Days in Year' | translate }}: + {{ + DailyDepositSchemeProduct.interestCalculationDaysInYearType + | find: DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearTypeOptions : 'id' : 'value' + }} +
+ + @if (DailyDepositSchemeProduct.effectiveDate) { +
+ Effective Date: + {{ DailyDepositSchemeProduct.effectiveDate | dateFormat }} +
+ } + + @if (DailyDepositSchemeProduct.averagePeriod) { +
+ Average Period: + {{ DailyDepositSchemeProduct.averagePeriod }} +
+ } + +
+ Auto Interest Post: + {{ DailyDepositSchemeProduct.autoPost | yesNo }} +
+ + @if ( + DailyDepositSchemeProduct.frequencyFixDay !== undefined && + DailyDepositSchemeProduct.frequencyFixDay !== null && + DailyDepositSchemeProduct.frequencyFixDay !== '' + ) { +
+ Frequency (Fix Day): + {{ DailyDepositSchemeProduct.frequencyFixDay }} +
+ } + + @if (DailyDepositSchemeProduct.lastInterestDate) { +
+ Last Interest Date: + {{ DailyDepositSchemeProduct.lastInterestDate | dateFormat }} +
+ } + + +

{{ 'labels.heading.Settings' | translate }}

+ + +
+ Loan Facility: + {{ DailyDepositSchemeProduct.loanFacility | yesNo }} +
+ +
+ Premature Withdrawal: + {{ DailyDepositSchemeProduct.prematureWithdrawalOption | yesNo }} +
+ +
+ Tenure Flexibility: + {{ DailyDepositSchemeProduct.tenureFlexibility | yesNo }} +
+ +
+ Lien Marked: + {{ DailyDepositSchemeProduct.lienMarked | yesNo }} +
+ +
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: + {{ DailyDepositSchemeProduct.withdrawalFeeForTransfers | yesNo }} +
+ +
+ Enable Lock-in Period: + {{ DailyDepositSchemeProduct.enableLockinPeriod | yesNo }} +
+ + @if (DailyDepositSchemeProduct.enableLockinPeriod && DailyDepositSchemeProduct.lockinPeriodFrequency) { +
+ Lock-in Period: + + {{ DailyDepositSchemeProduct.lockinPeriodFrequency }} + {{ getLockinPeriodFrequencyTypeLabel(DailyDepositSchemeProduct.lockinPeriodFrequencyType) }} + +
+ } + +
+ Maturity Instructions: + {{ DailyDepositSchemeProduct.maturityInstructions | yesNo }} +
+ +
+ Credit Maturity Proceeds: + {{ DailyDepositSchemeProduct.creditMaturityProceeds | yesNo }} +
+ +
+ Penalty Grace Period (Missed Installments): + {{ DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments | yesNo }} +
+ +
+ Panel Request Applicable: + {{ DailyDepositSchemeProduct.panelRequestApplicable | yesNo }} +
+ +
+ No Penalty (Nominee / Heir Closure): + {{ DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure | yesNo }} +
+ +
+ Deceased Settlement: + {{ DailyDepositSchemeProduct.deceasedSettlement | yesNo }} +
+ +
+ Allow Debit Txn After Due Date: + {{ DailyDepositSchemeProduct.allowDebitTxnAfterDueDate | yesNo }} +
+ +
+ Account Closed with Zero Balance in Day Closing: + {{ DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing | yesNo }} +
+ + @if (DailyDepositSchemeProduct.minBalanceForInterestCalculation) { +
+ Min Balance for Interest Calculation: + {{ DailyDepositSchemeProduct.minBalanceForInterestCalculation | formatNumber }} +
+ } + + + @if (DailyDepositSchemeProduct.rdSlabs?.length) { + +

{{ 'labels.heading.Interest Rate Charts' | translate }}

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? '—' }}Period{{ slab.periodTypeLabel }}Rate (%) + {{ slab.annualInterestRate }} +
+
+ } + + + @if (DailyDepositSchemeProduct.charges?.length) { +
+

{{ 'labels.inputs.Charges' | translate }}

+ + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }}{{ charge.name + ', ' + charge.currency.displaySymbol }}{{ 'labels.inputs.Type' | translate }}{{ charge.chargeCalculationType.value }}{{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value }}
+
+ } + + +

{{ 'labels.heading.Accounting' | translate }}

+ + + @if (isCashOrAccrualAccounting()) { +
+
+

GL Mapping

+
+ +
+
Assets
+
+ Savings Reference Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.savingsReferenceAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+ @if (isAccrualAccounting()) { +
+ Fees Receivable Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.feesReceivableAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+
+ Penalties Receivable Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.penaltiesReceivableAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+ } +
+ +
+
Liabilities
+
+ Savings Control Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.savingsControlAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions + ) + }} +
+
+ Transfers In Suspense Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.transfersInSuspenseAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions + ) + }} +
+ @if (isAccrualAccounting()) { +
+ Interest Payable Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.interestPayableAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions + ) + }} +
+ } +
+ +
+
Expenses
+
+ Interest on Savings Account + {{ + formatGlAccountById( + DailyDepositSchemeProduct.interestOnSavingsAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.expenseAccountOptions + ) + }} +
+
+ +
+
Income
+
+ Income from Fees + {{ + formatGlAccountById( + DailyDepositSchemeProduct.incomeFromFeeAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions + ) + }} +
+
+ Income from Penalties + {{ + formatGlAccountById( + DailyDepositSchemeProduct.incomeFromPenaltyAccountId, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions + ) + }} +
+
+
+ } + + + @if (DailyDepositSchemeProduct.otherGlMappings) { +
+
+

Other GL Mappings

+
+ + @if (DailyDepositSchemeProduct.otherGlMappings.assets?.length) { + + + + + + + + + + + +
Asset Account{{ row.accountName }}GL Account + {{ + formatGlAccountByCode( + row.glCode, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+ } + + @if (DailyDepositSchemeProduct.otherGlMappings.liabilities?.length) { + + + + + + + + + + + +
Liability Account{{ row.accountName }}GL Account + {{ + formatGlAccountByCode( + row.glCode, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions + ) + }} +
+ } + + @if (DailyDepositSchemeProduct.otherGlMappings.expenses?.length) { + + + + + + + + + + + +
Expense Account{{ row.accountName }}GL Account + {{ + formatGlAccountByCode( + row.glCode, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.expenseAccountOptions + ) + }} +
+ } + + @if (DailyDepositSchemeProduct.otherGlMappings.income?.length) { + + + + + + + + + + + +
Income Account{{ row.accountName }}GL Account + {{ + formatGlAccountByCode( + row.glCode, + DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions + ) + }} +
+ } +
+ } + + + @if ( + DailyDepositSchemeProduct.advancedAccountingRules && + (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length || + DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length || + DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) + ) { +
+

{{ 'labels.heading.Advanced Accounting Rules' | translate }}

+ + + @if (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length) { +
+

{{ 'labels.heading.Fund Sources for Payment Channels' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Payment Type' | translate }} + {{ item.paymentTypeId | find: DailyDepositSchemeProductsTemplate.paymentTypeOptions : 'id' : 'name' }} + {{ 'labels.inputs.Fund Source' | translate }} + {{ + item.fundSourceAccountId + | find + : DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions + : 'id' + : 'name' + }} +
+
+ } + + @if (DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length) { +
+

{{ 'labels.heading.Fees to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Fees' | translate }} + {{ item.chargeId | find: DailyDepositSchemeProductsTemplate.chargeOptions : 'id' : 'name' }} + {{ 'labels.inputs.Income Account' | translate }} + {{ + item.incomeAccountId + | find + : DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions + : 'id' + : 'name' + }} +
+
+ } + + @if (DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) { +
+

{{ 'labels.heading.Penalties to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Penalty' | translate }} + {{ item.chargeId | find: DailyDepositSchemeProductsTemplate.penaltyOptions : 'id' : 'name' }} + {{ 'labels.inputs.Income Account' | translate }} + {{ + item.incomeAccountId + | find + : DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions + : 'id' + : 'name' + }} +
+
+ } +
+ } +
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss new file mode 100644 index 000000000..33a0e1852 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss @@ -0,0 +1,131 @@ +/** + * 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/. + */ + +@use 'assets/styles/colours' as *; + +.mat-h3 { + margin-top: 10px !important; + font-weight: 300; + color: $status-approved; +} + +.mat-elevation-z1 { + margin: 1em 0 1.5em; +} + +h2, +h3, +h4 { + margin: 0; + font-weight: 500; +} + +table { + width: 100%; +} + +span { + margin: 0.5em 0; +} + +mat-divider { + margin: 0 0 0.5em; +} + +/* ── GL mapping summary (same as savings product preview) ── */ +.spa-gl-summary, +.spa-other-gl-summary { + display: flex; + flex-direction: column; + gap: 0.9rem; + margin-top: 1.25rem; +} + +.spa-gl-summary__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.25rem 0 0.5rem; +} + +.spa-gl-section { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 1.15rem; + border: 1px solid #e3e7f2; + border-radius: 12px; + background: #fff; + + h5 { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #91a0bc; + } +} + +.spa-gl-row { + display: grid; + grid-template-columns: minmax(220px, 40%) 1fr; + gap: 1rem; + align-items: baseline; + padding: 0.2rem 0; +} + +.spa-gl-row__label { + color: #1f2f4a; + font-weight: 500; +} + +.spa-gl-row__value { + color: #101828; + font-weight: 600; + word-break: break-word; +} + +.spa-other-gl-table { + margin-top: 0.5rem; + margin-bottom: 0.75rem; + table-layout: fixed; + width: 100%; + + &:first-of-type { + margin-top: 0.25rem; + } + + th.mat-mdc-header-cell, + td.mat-mdc-cell { + padding-left: 1.25rem; + padding-right: 1.25rem; + } + + th.mat-mdc-header-cell:first-child, + td.mat-mdc-cell:first-child { + width: 38%; + } + + th.mat-mdc-header-cell:last-child, + td.mat-mdc-cell:last-child { + width: 62%; + word-break: break-word; + } +} + +.spa-other-gl-summary .mat-elevation-z1 { + border-radius: 10px; + overflow: hidden; +} + +@media (width <= 768px) { + .spa-gl-row { + grid-template-columns: 1fr; + gap: 0.35rem; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts new file mode 100644 index 000000000..062751880 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts @@ -0,0 +1,142 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { Accounting } from 'app/core/utils/accounting'; +import { OptionData } from 'app/shared/models/option-data.model'; +import { MatDivider } from '@angular/material/divider'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { FindPipe } from '../../../../pipes/find.pipe'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; +import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; +import { YesnoPipe } from '../../../../pipes/yesno.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-preview-step', + templateUrl: './daily-deposit-scheme-product-preview-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-preview-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDivider, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + FaIconComponent, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + FindPipe, + DateFormatPipe, + FormatNumberPipe, + YesnoPipe + ] +}) +export class DailyDepositSchemeProductPreviewStepComponent implements OnInit, OnChanges { + private accounting = inject(Accounting); + + @Input() DailyDepositSchemeProductsTemplate: any; + @Input() chartSlabsDisplayedColumns: any[] = []; + @Input() accountingRuleData: any; + @Input() DailyDepositSchemeProduct: any; + @Output() submitEvent = new EventEmitter(); + + chargesDisplayedColumns: string[] = [ + 'name', + 'chargeCalculationType', + 'amount', + 'chargeTimeType' + ]; + paymentFundSourceDisplayedColumns: string[] = [ + 'paymentTypeId', + 'fundSourceAccountId' + ]; + feesPenaltyIncomeDisplayedColumns: string[] = [ + 'chargeId', + 'incomeAccountId' + ]; + + readonly rdSlabColumns = [ + 'srNo', + 'description', + 'fromPeriod', + 'toPeriod', + 'periodTypeLabel', + 'annualInterestRate' + ]; + + accountingRule: OptionData | undefined; + + ngOnInit() { + this.setCurrentValues(); + } + + ngOnChanges(_changes: SimpleChanges): void { + this.setCurrentValues(); + } + + setCurrentValues(): void { + if (this.DailyDepositSchemeProduct) { + this.accountingRule = this.accounting.getAccountingRuleFrom(this.DailyDepositSchemeProduct.accountingRule); + } + } + + isNoneAccounting(): boolean { + return this.accounting.isNoneAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule); + } + + isCashOrAccrualAccounting(): boolean { + return this.accounting.isCashOrAccrualAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule); + } + + isAccrualAccounting(): boolean { + return this.accounting.isAccrualAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule); + } + + getLockinPeriodFrequencyTypeLabel(value: any): string { + if (!value && value !== 0) return ''; + const options = this.DailyDepositSchemeProductsTemplate?.lockinPeriodFrequencyTypeOptions || []; + const found = options.find((o: any) => o.id === value); + return found?.value || String(value); + } + + getGlAccountName(id: any): string { + if (!id) return ''; + const accounts = this.DailyDepositSchemeProductsTemplate?.accountingMappingOptions?.liabilityAccountOptions ?? []; + const found = accounts.find((a: any) => a.id === id || a.id === +id); + return found ? `${found.name} (${found.glCode})` : String(id); + } + + formatGlAccountById(glAccountId: number, options: any[]): string { + if (!glAccountId || !Array.isArray(options)) return '-'; + const account = this.accounting.glAccountLookUp(glAccountId, options as any); + return account ? `(${account.glCode}) ${account.name}` : '-'; + } + + formatGlAccountByCode(glCode: string, options: any[]): string { + if (!glCode || !Array.isArray(options)) return glCode || '-'; + const account = options.find((o: any) => o?.glCode === glCode); + return account ? `(${account.glCode}) ${account.name}` : glCode; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html new file mode 100644 index 000000000..804ae09e9 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html @@ -0,0 +1,123 @@ + + +
+ +
+
+ + Premature option +
+
+
+ Loan Facility + Premature Withdrawal option + Tenure Flexibility + Lien Marked + Apply Withdrawal Fee for Transfers +
+
+
+ + +
+
+ + {{ 'labels.inputs.Lock-in Period' | translate }} +
+
+
+ Enable Lock-in Period + +
+ + @if (DailyDepositSchemeProductSettingsForm.value.enableLockinPeriod) { +
+ + Frequency* + + + + + Type* + + @for (lockinPeriodFrequencyType of lockinPeriodFrequencyTypeData; track lockinPeriodFrequencyType) { + + {{ lockinPeriodFrequencyType.value | translateKey: 'catalogs' }} + + } + + +
+ } +
+
+ + +
+
+ + Maturity Instructions +
+
+
+ Maturity Instructions + +
+
+ Option to credit maturity proceeds directly to the linked account or reinvest in another product (like + FD). + +
+
+ Penalty / Grace Period for Missed Installments + +
+
+ Panel Request Applicable + +
+
+ No penalty for nominee / legal heir closure + +
+
+ Deceased Settlement + +
+
+ Allow Debit Txn after due date + +
+
+
+ + +
+
+ + Additional Rules +
+
+
+
+ + Account Closed with Zero balance in Day Closing + +
+ + + Balance Required for Interest Calculation + + +
+
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss new file mode 100644 index 000000000..3aefca054 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss @@ -0,0 +1,164 @@ +/** + * 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/. + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.rdps-section { + border: 1px solid $border; + border-radius: 10px; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + span:not(.rdps-section__badge) { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.rdps-section__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: #eff6ff; + font-size: 14px; +} + +.rdps-body { + background: #fff; + padding: 18px 24px 24px; +} + +.rdps-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.rdps-premature-row { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + + mat-checkbox { + font-size: 14px; + color: $gray-900; + } +} + +.rdps-layout { + display: grid; + gap: 16px; +} + +.rdps-layout--additional { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + @media (width <= 1100px) { + grid-template-columns: 1fr; + } +} + +.rdps-toggle { + display: flex; + align-items: center; + min-height: 40px; + + mat-checkbox { + font-size: 14px; + color: $gray-900; + } + + &--full { + min-height: auto; + margin-bottom: 16px; + } +} + +.rdps-body--list { + padding: 0 24px; +} + +.rdps-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid $gray-200; + font-size: 14px; + color: $gray-900; + + &:last-child { + border-bottom: none; + } + + span { + max-width: 85%; + line-height: 1.4; + } +} + +.rdps-lockin-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid $gray-200; + font-size: 14px; + color: $gray-900; +} + +.rdps-lockin-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + + &--single { + grid-template-columns: minmax(0, 1fr); + max-width: 50%; + } + + @media (width <= 640px) { + grid-template-columns: 1fr; + + &--single { + max-width: 100%; + } + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts new file mode 100644 index 000000000..b7f3d26ac --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts @@ -0,0 +1,95 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-settings-step', + templateUrl: './daily-deposit-scheme-product-settings-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-settings-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class DailyDepositSchemeProductSettingsStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + + @Input() DailyDepositSchemeProductsTemplate: any; + + DailyDepositSchemeProductSettingsForm: UntypedFormGroup; + + lockinPeriodFrequencyTypeData: any; + + constructor() { + this.createDailyDepositSchemeProductSettingsForm(); + } + + ngOnInit() { + this.lockinPeriodFrequencyTypeData = this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequencyTypeOptions; + + if (this.DailyDepositSchemeProductsTemplate?.id) { + this.DailyDepositSchemeProductSettingsForm.patchValue({ + loanFacility: this.DailyDepositSchemeProductsTemplate.loanFacility ?? false, + prematureWithdrawalOption: this.DailyDepositSchemeProductsTemplate.prematureWithdrawalOption ?? false, + tenureFlexibility: this.DailyDepositSchemeProductsTemplate.tenureFlexibility ?? false, + lienMarked: this.DailyDepositSchemeProductsTemplate.lienMarked ?? false, + withdrawalFeeForTransfers: this.DailyDepositSchemeProductsTemplate.withdrawalFeeForTransfers ?? false, + enableLockinPeriod: this.DailyDepositSchemeProductsTemplate.enableLockinPeriod ?? false, + lockinPeriodFrequency: this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequency ?? '', + lockinPeriodFrequencyType: this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequencyType?.id ?? '', + maturityInstructions: this.DailyDepositSchemeProductsTemplate.maturityInstructions ?? false, + creditMaturityProceeds: this.DailyDepositSchemeProductsTemplate.creditMaturityProceeds ?? false, + penaltyGracePeriodMissedInstallments: + this.DailyDepositSchemeProductsTemplate.penaltyGracePeriodMissedInstallments ?? false, + panelRequestApplicable: this.DailyDepositSchemeProductsTemplate.panelRequestApplicable ?? false, + noPenaltyNomineeHeirClosure: this.DailyDepositSchemeProductsTemplate.noPenaltyNomineeHeirClosure ?? false, + deceasedSettlement: this.DailyDepositSchemeProductsTemplate.deceasedSettlement ?? false, + allowDebitTxnAfterDueDate: this.DailyDepositSchemeProductsTemplate.allowDebitTxnAfterDueDate ?? false, + accountClosedZeroBalanceInDayClosing: + this.DailyDepositSchemeProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false, + minBalanceForInterestCalculation: this.DailyDepositSchemeProductsTemplate.minBalanceForInterestCalculation ?? '' + }); + } + } + + createDailyDepositSchemeProductSettingsForm() { + this.DailyDepositSchemeProductSettingsForm = this.formBuilder.group({ + loanFacility: [false], + prematureWithdrawalOption: [false], + tenureFlexibility: [false], + lienMarked: [false], + withdrawalFeeForTransfers: [false], + enableLockinPeriod: [false], + lockinPeriodFrequency: [''], + lockinPeriodFrequencyType: [''], + maturityInstructions: [false], + creditMaturityProceeds: [false], + penaltyGracePeriodMissedInstallments: [false], + panelRequestApplicable: [false], + noPenaltyNomineeHeirClosure: [false], + deceasedSettlement: [false], + allowDebitTxnAfterDueDate: [false], + accountClosedZeroBalanceInDayClosing: [false], + minBalanceForInterestCalculation: [''] + }); + } + + get DailyDepositSchemeProductSettings() { + const settings = { ...this.DailyDepositSchemeProductSettingsForm.value }; + for (const key in settings) { + if (settings[key] === '') { + delete settings[key]; + } + } + return settings; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html new file mode 100644 index 000000000..45c6f2100 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html @@ -0,0 +1,192 @@ + + +
+
+
+ + Deposit Amount +
+
+
+ + Minimum + + + + + Maximum + + +
+
+
+
+
+ + Interest Rate & Posting Terms +
+
+
+ ℹ️ + + Daily Deposit Scheme base rate: + {{ DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').value }}% + +
+ +
+ + {{ 'labels.inputs.Nominal Annual Interest' | translate }} + + @if (DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').hasError('required')) { + + {{ 'labels.inputs.Nominal Annual Interest' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) { + {{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater + } + + + + Effective Date + + + + + + + {{ 'labels.inputs.Interest Calculation Type' | translate }} + + @for (opt of interestCalculationTypeOptionData; track opt.id) { + {{ opt.value }} + } + + +
+ +
+ + Interest Calculate + + @for (mode of interestCalculationTypeData; track mode.id) { + {{ mode.value | translateKey: 'catalogs' }} + } + + + {{ 'labels.inputs.Interest Calculation Type' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + Interest Compounding Period + + @for (opt of interestCompoundingPeriodTypeData; track opt.id) { + {{ opt.value | translateKey: 'catalogs' }} + } + + Interest Compounding Period is required + + + + Days in Year + + @for (opt of interestCalculationDaysInYearTypeData; track opt.id) { + {{ opt.value | translateKey: 'catalogs' }} + } + + Days in Year is required + + + @if (DailyDepositSchemeProductTermsForm.value.interestCalculationType === 2) { + + Average Period + + @for (period of periodFrequencyData; track period.id) { + {{ period.value }} + } + + + } + + + {{ 'labels.inputs.Interest Posting Period' | translate }} + + @for (interestPostingPeriodType of interestPostingPeriodTypeData; track interestPostingPeriodType) { + + {{ interestPostingPeriodType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest Posting Period' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + +
+ + +
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss new file mode 100644 index 000000000..b2e03a37d --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss @@ -0,0 +1,151 @@ +/** + * 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/. + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.rdpt-section { + border: 1px solid $border; + border-radius: 10px; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3, + span:not(.rdpt-section__badge) { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.rdpt-section__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: #eff6ff; + font-size: 14px; +} + +.rdpt-body { + background: #fff; +} + +.rdpt-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + &--three { + grid-template-columns: repeat(3, 1fr); + } + + @media (width <= 600px) { + grid-template-columns: 1fr; + + &--three { + grid-template-columns: 1fr; + } + } +} + +.rdpt-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.rdpt-banner { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 24px 0; + padding: 12px 14px; + background: #eff6ff; + border: 1px solid #dbeafe; + border-radius: 8px; + color: #1d4ed8; + font-size: 13px; + + &__icon { + font-size: 14px; + } +} + +.rdpt-footer { + padding: 0 24px 24px; + border-top: 1px solid #f1f5f9; +} + +.rdpt-footer__grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + padding-top: 18px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rdpt-toggle-card { + padding: 12px 0; + + h4 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + + &__choices { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + } +} + +.rdpt-note { + display: flex; + align-items: center; + gap: 10px; + margin-top: 18px; + padding: 12px 14px; + border: 1px solid #fde68a; + background: #fffbeb; + color: #b45309; + border-radius: 8px; + font-size: 12px; +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts new file mode 100644 index 000000000..0d42c8ff1 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts @@ -0,0 +1,169 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Dates } from 'app/core/utils/dates'; +import { SettingsService } from 'app/settings/settings.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-product-terms-step', + templateUrl: './daily-deposit-scheme-product-terms-step.component.html', + styleUrls: ['./daily-deposit-scheme-product-terms-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTooltip + ] +}) +export class DailyDepositSchemeProductTermsStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private dateUtils = inject(Dates); + private settingsService = inject(SettingsService); + + @Input() DailyDepositSchemeProductsTemplate: any; + + DailyDepositSchemeProductTermsForm: UntypedFormGroup; + + interestCompoundingPeriodTypeData: any; + interestPostingPeriodTypeData: any; + interestCalculationTypeData: any; + interestCalculationTypeOptionData: any; + interestCalculationDaysInYearTypeData: any; + periodFrequencyData: any[] = []; + yesNoOptions: Array<{ value: boolean; label: string }> = [ + { value: true, label: 'Yes' }, + { value: false, label: 'No' } + ]; + + constructor() { + this.createDailyDepositSchemeProductTermsForm(); + } + + ngOnInit() { + this.interestCompoundingPeriodTypeData = + this.DailyDepositSchemeProductsTemplate.interestCompoundingPeriodTypeOptions; + this.interestPostingPeriodTypeData = this.DailyDepositSchemeProductsTemplate.interestPostingPeriodTypeOptions; + // Standard enum (1=Daily, 2=Average) → "Interest Calculate" dropdown → interestCalculationType form control + this.interestCalculationTypeData = this.DailyDepositSchemeProductsTemplate.interestCalculationTypeOptions; + // CBS code values → "Interest Calculation Type" dropdown → interestCalculateSelector form control + this.interestCalculationTypeOptionData = + this.DailyDepositSchemeProductsTemplate.interestCalculationTypeOption || []; + this.interestCalculationDaysInYearTypeData = + this.DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearTypeOptions; + this.periodFrequencyData = this.DailyDepositSchemeProductsTemplate.periodFrequencyOptions || []; + this.setYesNoOptions(this.DailyDepositSchemeProductsTemplate?.yesNoOptions); + + const isEditing = !!this.DailyDepositSchemeProductsTemplate.id; + const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly'; + const savedEnumTypeId = + this.DailyDepositSchemeProductsTemplate.interestCalculationType?.id || + this.interestCalculationTypeData?.[0]?.id || + 1; + const savedSelectorId = isEditing + ? (this.DailyDepositSchemeProductsTemplate.interestCalculateSelector ?? + this.interestCalculationTypeOptionData?.[0]?.id ?? + null) + : (this.interestCalculationTypeOptionData?.[0]?.id ?? null); + + this.DailyDepositSchemeProductTermsForm.patchValue({ + nominalAnnualInterestRate: this.DailyDepositSchemeProductsTemplate.nominalAnnualInterestRate ?? 0, + interestCompoundingPeriodType: this.DailyDepositSchemeProductsTemplate.interestCompoundingPeriodType?.id, + interestPostingPeriodType: this.DailyDepositSchemeProductsTemplate.interestPostingPeriodType?.id, + interestCalculationType: savedEnumTypeId, + interestCalculationDaysInYearType: this.DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearType?.id, + effectiveDate: this.DailyDepositSchemeProductsTemplate.effectiveDate + ? this.parseDate(this.DailyDepositSchemeProductsTemplate.effectiveDate) + : '', + interestCalculateSelector: savedSelectorId, + averagePeriod: isEditing ? this.DailyDepositSchemeProductsTemplate.averagePeriod || defaultPeriod : defaultPeriod, + autoPost: isEditing ? (this.DailyDepositSchemeProductsTemplate.autoPost ?? true) : true, + frequencyFixDay: isEditing ? (this.DailyDepositSchemeProductsTemplate.frequencyFixDay ?? 30) : 30, + lastInterestDate: this.DailyDepositSchemeProductsTemplate.lastInterestDate + ? this.parseDate(this.DailyDepositSchemeProductsTemplate.lastInterestDate) + : '', + minDepositAmount: this.DailyDepositSchemeProductsTemplate.minDepositAmount ?? '', + maxDepositAmount: this.DailyDepositSchemeProductsTemplate.maxDepositAmount ?? '' + }); + } + + private parseDate(dateValue: any): Date | null { + if (!dateValue) return null; + if (Array.isArray(dateValue)) { + return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); + } + if (dateValue instanceof Date) return dateValue; + return new Date(dateValue); + } + + private setYesNoOptions(options: any[]) { + if (!Array.isArray(options)) return; + const mapped = options + .map((opt: any) => { + const raw = opt?.name ?? opt?.value ?? opt; + const norm = typeof raw === 'string' ? raw.trim().toUpperCase() : raw; + if (norm === 'YES') return { value: true, label: 'Yes' }; + if (norm === 'NO') return { value: false, label: 'No' }; + return null; + }) + .filter(Boolean); + if (mapped.length) this.yesNoOptions = mapped; + } + + createDailyDepositSchemeProductTermsForm() { + this.DailyDepositSchemeProductTermsForm = this.formBuilder.group({ + nominalAnnualInterestRate: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + interestCompoundingPeriodType: [ + '', + Validators.required + ], + interestPostingPeriodType: [ + '', + Validators.required + ], + interestCalculationType: [ + '', + Validators.required + ], + interestCalculationDaysInYearType: [ + '', + Validators.required + ], + effectiveDate: [''], + interestCalculateSelector: [1], + averagePeriod: ['Monthly'], + autoPost: [true], + frequencyFixDay: [ + 30, + [Validators.min(0)] + ], + lastInterestDate: [''], + minDepositAmount: [''], + maxDepositAmount: [''] + }); + } + + get DailyDepositSchemeProductTerms() { + const formValue = { ...this.DailyDepositSchemeProductTermsForm.value }; + const dateFormat = this.settingsService.dateFormat; + + if (formValue.effectiveDate instanceof Date) { + formValue.effectiveDate = this.dateUtils.formatDate(formValue.effectiveDate, dateFormat) || ''; + } + formValue.minDepositAmount = formValue.minDepositAmount; + formValue.maxDepositAmount = formValue.maxDepositAmount; + return formValue; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts new file mode 100644 index 000000000..0ec83acee --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts @@ -0,0 +1,37 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ProductsService } from '../products.service'; + +/** + * Daily Deposit Scheme Product data resolver. + * + * Reuses the Recurring Deposit product read API — a Daily Deposit Scheme + * product is a Recurring Deposit product under the hood. + */ +@Injectable() +export class DailyDepositSchemeProductResolver { + private productsService = inject(ProductsService); + + /** + * Returns the Daily Deposit Scheme product data. + * @returns {Observable} + */ + resolve(route: ActivatedRouteSnapshot): Observable { + const productId = route.parent.paramMap.get('productId'); + return this.productsService.getRecurringDepositProduct(productId); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html new file mode 100644 index 000000000..b2973f1f2 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html @@ -0,0 +1,172 @@ + + +
+
+
+
+
+

{{ 'labels.heading.Daily Deposit Scheme Products' | translate }}

+

Manage Daily Deposit Scheme products — daily instalment amount, interest rates and charges.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ +
+
+ +
+
+
+
+
{{ totalCount | number }}
+
Total DDS Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No Daily Deposit Scheme products yet

+

Create your first Daily Deposit Scheme product to enable daily-instalment onboarding.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
{{ p.currency.code }}
+ } +
+
+
{{ 'labels.inputs.Short Name' | translate }} + {{ p.shortName }} + Currency + @if (p.currency?.code) { + {{ p.currency.code }} + } @else { + — + } + Actions + + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss new file mode 100644 index 000000000..a880f9b22 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss @@ -0,0 +1,17 @@ +/** + * 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/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* Daily Deposit Scheme accent */ +:host { + --pl-accent: #0f766e; + --pl-accent-l: #ccfbf1; +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts new file mode 100644 index 000000000..7af670304 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts @@ -0,0 +1,124 @@ +/** + * 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, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { ActivatedRoute } from '@angular/router'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Daily Deposit Scheme Products component. + * + * Lists Daily Deposit Scheme products. These are Recurring Deposit products + * under the hood (same create/read API) so this list reuses that data source. + */ +@Component({ + selector: 'mifosx-daily-deposit-scheme-products', + templateUrl: './daily-deposit-scheme-products.component.html', + styleUrls: ['./daily-deposit-scheme-products.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTooltip, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator + ] +}) +export class DailyDepositSchemeProductsComponent implements OnInit { + private route = inject(ActivatedRoute); + + /** Data table data. */ + productData: any[] = []; + /** Columns to be displayed in the Daily Deposit Scheme products table. */ + displayedColumns: string[] = [ + 'name', + 'shortName', + 'currency', + 'actions' + ]; + /** Data source for the table. */ + dataSource: MatTableDataSource; + + filteredCount = 0; + + get totalCount(): number { + return this.productData?.length || 0; + } + + get currencyCount(): number { + const set = new Set(); + for (const p of this.productData || []) { + if (p?.currency?.code) set.add(p.currency.code); + } + return set.size; + } + + /** Paginator for the table. */ + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + /** Sorter for the table. */ + @ViewChild(MatSort, { static: true }) sort: MatSort; + + constructor() { + this.route.data.subscribe((data: { dailyDepositSchemeProducts: any }) => { + this.productData = (data.dailyDepositSchemeProducts || []).filter( + (product: any) => product.productCategory === 'DDS' + ); + }); + } + + ngOnInit() { + this.setProducts(); + } + + /** + * Filters data in the table based on the passed value. + * @param {string} filterValue Value to filter data. + */ + applyFilter(filterValue: string) { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * Initializes the data source, paginator and sorter for the table. + */ + setProducts() { + this.dataSource = new MatTableDataSource(this.productData); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.totalCount; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts new file mode 100644 index 000000000..544bdbe0c --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts @@ -0,0 +1,35 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ProductsService } from '../products.service'; + +/** + * Daily Deposit Scheme Products data resolver. + * + * A Daily Deposit Scheme product is a Recurring Deposit product under the hood, + * so this reuses the same read API as Recurring Deposit Products. + */ +@Injectable() +export class DailyDepositSchemeProductsResolver { + private productsService = inject(ProductsService); + + /** + * Returns the Daily Deposit Scheme products data. + * @returns {Observable} + */ + resolve(): Observable { + return this.productsService.getRecurringDepositProducts(); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts new file mode 100644 index 000000000..ca7c90dfb --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts @@ -0,0 +1,35 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ProductsService } from '../../products.service'; + +/** + * Recurring Deposits Account Template resolver. + */ +@Injectable() +export class DailyDepositSchemeProductAndTemplateResolver { + private productsService = inject(ProductsService); + + /** + * Returns the Recurring Deposits Product and Template. + * @param {ActivatedRouteSnapshot} route Route Snapshot + * @returns {Observable} + */ + resolve(route: ActivatedRouteSnapshot): Observable { + const productId = route.parent.paramMap.get('productId'); + return this.productsService.getRecurringDepositProductAndTemplate(productId); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html new file mode 100644 index 000000000..3ce623701 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html @@ -0,0 +1,202 @@ + + +
+
+ +

{{ 'labels.heading.Edit Daily Deposit Scheme Product' | translate }}

+
+ + + + + + {{ 'labels.inputs.DETAILS' | translate }} + +
+ +
+
+ + + + + {{ 'labels.inputs.CURRENCY' | translate }} + +
+ +
+
+ + + + + {{ 'labels.inputs.TERMS' | translate }} + +
+ +
+
+ + + + + {{ 'labels.inputs.SETTINGS' | translate }} + +
+ +
+
+ + + + + CBS Base Chart + +
+ @if (activeBaseVersion) { +
+
+
+

{{ activeBaseVersion.versionLabel }}

+

+ Active CBS base chart + Effective from {{ activeBaseVersion.effectiveFromDate }} +

+
+ Active +
+ + @if (activeBaseVersion.remarks) { +

{{ activeBaseVersion.remarks }}

+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? 'N/A' }}Period Type{{ slab.periodTypeLabel }}Annual Rate (%) + {{ slab.annualInterestRate | number: '1.2-2' }} +
+
+ } @else { +

Loading CBS base chart...

+ } +
+
+ + + + + {{ 'labels.inputs.CHARGES' | translate }} + +
+ +
+
+ + + + + {{ 'labels.inputs.ACCOUNTING' | translate }} + +
+ + +
+
+ + @if (DailyDepositSchemeProductFormValid) { + + + + {{ 'labels.inputs.PREVIEW' | translate }} + +
+ +
+
+ } +
+ +
+ +
+ @if (selectedTabIndex > 0) { + + } + @if (selectedTabIndex < totalTabs - 1) { + + } + @if (DailyDepositSchemeProductFormValid && selectedTabIndex === totalTabs - 1) { + + } +
+
+
diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss new file mode 100644 index 000000000..c04c383f8 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss @@ -0,0 +1,138 @@ +/** + * 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/. + */ + +$primary: #1353a0; + +.rdp-container { + max-width: 960px; + margin: 0 auto; + padding: 24px 16px; +} + +.rdp-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; + + &__icon { + font-size: 20px; + color: $primary; + } + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.rdp-tabs { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + + ::ng-deep { + .mat-mdc-tab-header { + border-bottom: 1px solid rgb(0 0 0 / 8%); + } + + .mat-mdc-tab { + min-width: 100px; + padding: 0 12px; + height: 52px; + } + + .mat-mdc-tab-body-wrapper { + min-height: 200px; + } + + .mat-mdc-tab-body { + overflow: visible !important; + } + + .mat-mdc-tab-body-content { + overflow: visible !important; + } + } +} + +.rdp-tab-icon { + margin-right: 8px; + font-size: 14px; +} + +.rdp-tab-content { + padding: 24px 16px; +} + +.base-rate-card { + padding: 16px; + border: 1px solid #dbe4f0; + border-radius: 14px; + background: linear-gradient(180deg, #f8fbff 0%, #fff 100%); +} + +.base-rate-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.base-rate-title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.base-rate-meta { + margin: 6px 0 0; + color: #5b6b7f; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.base-rate-badge { + padding: 4px 10px; + border-radius: 999px; + background: #dcfce7; + color: #166534; + font-size: 12px; + font-weight: 700; +} + +.base-rate-remarks { + margin: 0 0 12px; + color: #334155; +} + +.base-rate-table { + width: 100%; + border-collapse: collapse; +} + +.rdp-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__cancel { + color: rgb(0 0 0 / 60%); + } + + &__right { + display: flex; + gap: 12px; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts new file mode 100644 index 000000000..38d715746 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts @@ -0,0 +1,305 @@ +/** + * 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, ViewChild, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +/** Custom Components */ +import { DailyDepositSchemeProductDetailsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component'; +import { DailyDepositSchemeProductCurrencyStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component'; +import { DailyDepositSchemeProductTermsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component'; +import { DailyDepositSchemeProductSettingsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component'; +import { DailyDepositSchemeProductChargesStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component'; +import { DailyDepositSchemeProductAccountingStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Accounting } from 'app/core/utils/accounting'; +import { Dates } from 'app/core/utils/dates'; +import { MatTabsModule } from '@angular/material/tabs'; +import { DailyDepositSchemeProductPreviewStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-edit-daily-deposit-scheme-product', + templateUrl: './edit-daily-deposit-scheme-product.component.html', + styleUrls: ['./edit-daily-deposit-scheme-product.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabsModule, + DailyDepositSchemeProductDetailsStepComponent, + DailyDepositSchemeProductCurrencyStepComponent, + DailyDepositSchemeProductTermsStepComponent, + DailyDepositSchemeProductSettingsStepComponent, + DailyDepositSchemeProductChargesStepComponent, + DailyDepositSchemeProductAccountingStepComponent, + DailyDepositSchemeProductPreviewStepComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow + ] +}) +export class EditDailyDepositSchemeProductComponent { + private route = inject(ActivatedRoute); + private productsService = inject(ProductsService); + private router = inject(Router); + private settingsService = inject(SettingsService); + private accounting = inject(Accounting); + private dateUtils = inject(Dates); + + @ViewChild(DailyDepositSchemeProductDetailsStepComponent, { static: true }) + DailyDepositSchemeProductDetailsStep: DailyDepositSchemeProductDetailsStepComponent; + @ViewChild(DailyDepositSchemeProductCurrencyStepComponent, { static: true }) + DailyDepositSchemeProductCurrencyStep: DailyDepositSchemeProductCurrencyStepComponent; + @ViewChild(DailyDepositSchemeProductTermsStepComponent, { static: true }) + DailyDepositSchemeProductTermsStep: DailyDepositSchemeProductTermsStepComponent; + @ViewChild(DailyDepositSchemeProductSettingsStepComponent, { static: true }) + DailyDepositSchemeProductSettingsStep: DailyDepositSchemeProductSettingsStepComponent; + @ViewChild(DailyDepositSchemeProductChargesStepComponent, { static: true }) + DailyDepositSchemeProductChargesStep: DailyDepositSchemeProductChargesStepComponent; + @ViewChild(DailyDepositSchemeProductAccountingStepComponent, { static: true }) + DailyDepositSchemeProductAccountingStep: DailyDepositSchemeProductAccountingStepComponent; + + DailyDepositSchemeProductsTemplate: any; + accountingRuleData: string[] = []; + rdInterestRates: any[] = []; + readonly rdSlabColumns = [ + 'srNo', + 'description', + 'fromPeriod', + 'toPeriod', + 'periodTypeLabel', + 'annualInterestRate' + ]; + selectedTabIndex = 0; + + /** + * @param {ActivatedRoute} route Activated Route. + * @param {ProductsService} productsService Products Service. + * @param {Router} router Router for navigation. + * @param {SettingsService} settingsService Settings Service. + */ + + constructor() { + this.route.data.subscribe((data: { DailyDepositSchemeProductAndTemplate: any }) => { + this.DailyDepositSchemeProductsTemplate = data.DailyDepositSchemeProductAndTemplate; + this.productsService.getFdBaseInterestRates().subscribe({ + next: (rates: any[]) => { + this.rdInterestRates = rates || []; + }, + error: () => { + this.rdInterestRates = []; + } + }); + }); + this.accountingRuleData = this.accounting.getAccountingRulesForSavings(); + } + + get DailyDepositSchemeProductDetailsForm() { + return this.DailyDepositSchemeProductDetailsStep?.DailyDepositSchemeProductDetailsForm; + } + + get DailyDepositSchemeProductCurrencyForm() { + return this.DailyDepositSchemeProductCurrencyStep?.DailyDepositSchemeProductCurrencyForm; + } + + get DailyDepositSchemeProductTermsForm() { + return this.DailyDepositSchemeProductTermsStep?.DailyDepositSchemeProductTermsForm; + } + + get DailyDepositSchemeProductSettingsForm() { + return this.DailyDepositSchemeProductSettingsStep?.DailyDepositSchemeProductSettingsForm; + } + + get DailyDepositSchemeProductAccountingForm() { + return this.DailyDepositSchemeProductAccountingStep?.DailyDepositSchemeProductAccountingForm; + } + + get totalTabs(): number { + return this.DailyDepositSchemeProductFormValid ? 8 : 7; + } + + get DailyDepositSchemeProductFormValid() { + return !!this.DailyDepositSchemeProductDetailsForm?.valid; + } + + get activeBaseVersion() { + const chart = + this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ?? + this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ?? + this.rdInterestRates[0]; + if (chart) { + return { + ...chart, + versionLabel: chart.versionLabel?.replace('FD', 'RD'), + remarks: chart.remarks?.replace('FD', 'RD') + }; + } + return null; + } + + get activeBaseSlabs() { + return this.activeBaseVersion?.slabs || []; + } + + get DailyDepositSchemeProduct() { + // accountTypeGroup / autoInterestPost / autoInterestPostWhen are the legacy Account Type + // Creation screen's own fields (added for the simplified Create form) - not real product API + // parameters, so they must not leak into the update payload the way a plain spread would. + // accountTypeGroup is deliberately NOT re-folded into description here (unlike Create): it + // was already folded in once at creation time, and this getter re-runs on every change + // detection cycle while editing, so re-prepending it here would keep stacking duplicate + // "Group - " prefixes onto the same description. autoInterestPost/When aren't re-synced onto + // the Terms tab either, since Edit's own Terms/Settings/Accounting tabs remain fully visible + // and directly editable. + const { accountTypeGroup, autoInterestPost, autoInterestPostWhen, autoInterestPostDay, ...detailsPayload } = + this.DailyDepositSchemeProductDetailsStep.DailyDepositSchemeProductDetails; + + return { + ...detailsPayload, + ...this.DailyDepositSchemeProductCurrencyStep.DailyDepositSchemeProductCurrency, + ...this.DailyDepositSchemeProductTermsStep.DailyDepositSchemeProductTerms, + ...this.DailyDepositSchemeProductSettingsStep.DailyDepositSchemeProductSettings, + ...this.DailyDepositSchemeProductChargesStep.DailyDepositSchemeProductCharges, + ...this.DailyDepositSchemeProductAccountingStep.DailyDepositSchemeProductAccounting, + // charts intentionally omitted here - the backend no longer requires a charts payload for RD + // updates once rdSlabs is present, and this getter's chart data comes from the shared CBS + // base-rate template (not this product's own chart), which caused spurious + // "chart.overlapping.from.and.end.dates" errors when a product's real chart had different + // dates. Still included in DailyDepositSchemeProductPreview below for the read-only display tab. + rdSlabs: this.activeBaseSlabs + }; + } + + get DailyDepositSchemeProductPreview() { + return { + ...this.DailyDepositSchemeProduct, + charts: this.DailyDepositSchemeProductCharts, + rdSlabs: this.activeBaseSlabs + }; + } + + get DailyDepositSchemeProductCharts() { + const activeBaseVersion = this.activeBaseVersion; + if (!activeBaseVersion?.slabs?.length) { + return []; + } + const dateFormat = this.settingsService.dateFormat; + + return [ + { + name: activeBaseVersion.versionLabel || 'CBS Base Chart', + description: activeBaseVersion.remarks || activeBaseVersion.versionLabel || 'CBS base chart', + fromDate: + this.dateUtils.formatDate(activeBaseVersion.effectiveFromDate, dateFormat) || + activeBaseVersion.effectiveFromDate, + ...(activeBaseVersion.effectiveToDate + ? { + endDate: + this.dateUtils.formatDate(activeBaseVersion.effectiveToDate, dateFormat) || + activeBaseVersion.effectiveToDate + } + : {}), + isPrimaryGroupingByAmount: false, + locale: this.settingsService.language.code, + dateFormat, + chartSlabs: activeBaseVersion.slabs.map((slab: any) => { + const chartSlab: any = { + periodType: slab.periodTypeEnum, + fromPeriod: slab.fromPeriod, + toPeriod: slab.toPeriod, + annualInterestRate: slab.annualInterestRate, + description: slab.description || '', + incentives: [] as any[] + }; + + if (slab.amountRangeFrom !== null && slab.amountRangeFrom !== undefined && slab.amountRangeFrom !== '') { + chartSlab.amountRangeFrom = slab.amountRangeFrom; + } + + if (slab.amountRangeTo !== null && slab.amountRangeTo !== undefined && slab.amountRangeTo !== '') { + chartSlab.amountRangeTo = slab.amountRangeTo; + } + + return chartSlab; + }) + } + ] as any[]; + } + + submit() { + const DailyDepositSchemeProduct: any = this.pruneEmptyValues({ + ...this.DailyDepositSchemeProduct, + charges: (this.DailyDepositSchemeProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), + locale: this.settingsService.language.code, + dateFormat: this.settingsService.dateFormat + }); + if (!DailyDepositSchemeProduct.description) { + DailyDepositSchemeProduct.description = ''; + } + delete DailyDepositSchemeProduct.advancedAccountingRules; + const productId = this.DailyDepositSchemeProductsTemplate.id; + this.productsService + .updateRecurringDepositProduct(productId, DailyDepositSchemeProduct) + .subscribe((response: any) => { + this.router.navigate(['../'], { relativeTo: this.route }); + }); + } + + private pruneEmptyValues(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => this.pruneEmptyValues(item)).filter((item) => item !== undefined) as T; + } + + if (value && typeof value === 'object') { + return Object.entries(value as Record).reduce( + (acc, [ + key, + currentValue + ]) => { + const cleanedValue = this.pruneEmptyValues(currentValue); + if (cleanedValue === undefined) { + return acc; + } + return { + ...acc, + [key]: cleanedValue + }; + }, + {} as Record + ) as T; + } + + if (value === '' || value === null || value === undefined) { + return undefined as T; + } + + return value; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html new file mode 100644 index 000000000..c8d7995ea --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html @@ -0,0 +1,17 @@ + + +
+ + +
diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss new file mode 100644 index 000000000..af9f4c075 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss @@ -0,0 +1,7 @@ +/** + * 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/. + */ diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts new file mode 100644 index 000000000..4d5a895ed --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts @@ -0,0 +1,38 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { EntityDatatableTabComponent } from '../../../../shared/tabs/entity-datatable-tab/entity-datatable-tab.component'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-datatable-tab', + templateUrl: './daily-deposit-scheme-datatable-tab.component.html', + styleUrls: ['./daily-deposit-scheme-datatable-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + EntityDatatableTabComponent + ] +}) +export class DailyDepositSchemeDatatableTabComponent { + private route = inject(ActivatedRoute); + + entityId: string; + entityDatatable: any; + multiRowDatatableFlag: boolean; + + constructor() { + this.entityId = this.route.parent.parent.snapshot.paramMap.get('productId'); + + this.route.data.subscribe((data: { recurringDepositDatatable: any }) => { + this.entityDatatable = data.recurringDepositDatatable; + this.multiRowDatatableFlag = this.entityDatatable.columnHeaders[0].columnName === 'id' ? true : false; + }); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html new file mode 100644 index 000000000..8881ab235 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html @@ -0,0 +1,843 @@ + + +
+ +
+
+
+
+
Daily Deposit Scheme Product
+

{{ DailyDepositSchemeProduct.name }}

+
+ {{ DailyDepositSchemeProduct.shortName }} + @if (DailyDepositSchemeProduct.currency?.code) { + {{ DailyDepositSchemeProduct.currency.code }} + } + + + {{ DailyDepositSchemeProduct.endDate ? 'Inactive' : 'Active' }} + +
+ @if (DailyDepositSchemeProduct.description) { +

{{ DailyDepositSchemeProduct.description }}

+ } +
+
+ +
+ + +
+
+
+

{{ 'labels.inputs.Details' | translate }}

+
+
+
+ {{ 'labels.inputs.Short Name' | translate }} + {{ DailyDepositSchemeProduct.shortName }} +
+ @if (DailyDepositSchemeProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }} + {{ DailyDepositSchemeProduct.description }} +
+ } +
+
+ + +
+
+
+

Product Classification

+
+
+ @if (DailyDepositSchemeProduct.accountType) { +
+ Account Type + {{ DailyDepositSchemeProduct.accountType }} +
+ } + @if (DailyDepositSchemeProduct.customerSegment) { +
+ Customer Segment + {{ DailyDepositSchemeProduct.customerSegment }} +
+ } + @if (DailyDepositSchemeProduct.glAccountId) { +
+ GL Account (Liability) + {{ + DailyDepositSchemeProduct.glAccountName || DailyDepositSchemeProduct.glAccountId + }} +
+ } + @if (DailyDepositSchemeProduct.effectiveTo) { +
+ Effective To + {{ DailyDepositSchemeProduct.effectiveTo | dateFormat }} +
+ } + @if (DailyDepositSchemeProduct.endDate) { +
+ End Date + {{ DailyDepositSchemeProduct.endDate | dateFormat }} +
+ } +
+ Introducer Required + + + {{ DailyDepositSchemeProduct.introducerRequired | yesNo }} + + +
+
+ Deposit Account Link + + + {{ DailyDepositSchemeProduct.shareLinkingEnabled | yesNo }} + + +
+
+
+ + +
+
+
+

{{ 'labels.inputs.Currency' | translate }}

+
+
+
+ {{ 'labels.inputs.Currency' | translate }} + ({{ DailyDepositSchemeProduct.currency.code }}) {{ DailyDepositSchemeProduct.currency.name }} +
+
+ {{ 'labels.inputs.Decimal Places' | translate }} + {{ DailyDepositSchemeProduct.currency.decimalPlaces }} +
+
+ Dormancy Alert + {{ DailyDepositSchemeProduct.dormancyAlert | yesNo }} +
+
+ Balance Swap Option + {{ DailyDepositSchemeProduct.balanceSwapOption | yesNo }} +
+
+ MSME + {{ DailyDepositSchemeProduct.msme | yesNo }} +
+
+ Nomination Facility + {{ DailyDepositSchemeProduct.nominationFacility | yesNo }} +
+
+ Auto Debit Facility + {{ DailyDepositSchemeProduct.autoDebitFacility | yesNo }} +
+
+
+ + +
+
+
+

{{ 'labels.inputs.Terms' | translate }}

+
+
+
+ {{ 'labels.inputs.Deposit Amount' | translate }} + + {{ DailyDepositSchemeProduct.depositAmount | formatNumber }} + (Min: {{ DailyDepositSchemeProduct.minDepositAmount || DailyDepositSchemeProduct.depositAmount }}, Max: + {{ DailyDepositSchemeProduct.maxDepositAmount || DailyDepositSchemeProduct.depositAmount }}) + +
+
+ {{ 'labels.inputs.Nominal Annual Interest' | translate }} + {{ DailyDepositSchemeProduct.nominalAnnualInterestRate | formatNumber + }}% +
+ @if (DailyDepositSchemeProduct.effectiveDate) { +
+ Effective Date + {{ DailyDepositSchemeProduct.effectiveDate | dateFormat }} +
+ } + @if (DailyDepositSchemeProduct.interestCalculationTypeName) { +
+ {{ 'labels.inputs.Interest Calculation Type' | translate }} + {{ DailyDepositSchemeProduct.interestCalculationTypeName }} +
+ } +
+ Interest Calculate + {{ DailyDepositSchemeProduct.interestCalculationType?.value }} +
+ @if (DailyDepositSchemeProduct.averagePeriod) { +
+ Average Period + {{ DailyDepositSchemeProduct.averagePeriod }} +
+ } +
+ {{ 'labels.inputs.Interest Posting Period' | translate }} + {{ DailyDepositSchemeProduct.interestPostingPeriodType.value }} +
+
+ {{ 'labels.inputs.Interest Compounding Period' | translate }} + {{ DailyDepositSchemeProduct.interestCompoundingPeriodType.value }} +
+
+ {{ 'labels.inputs.Days in Year' | translate }} + {{ DailyDepositSchemeProduct.interestCalculationDaysInYearType.value }} +
+
+ Auto Post + + + {{ DailyDepositSchemeProduct.autoPost | yesNo }} + + +
+ @if (DailyDepositSchemeProduct.frequencyFixDay) { +
+ Frequency (Fix Day) + {{ DailyDepositSchemeProduct.frequencyFixDay }} +
+ } + @if (DailyDepositSchemeProduct.lastInterestDate) { +
+ Last Interest Date + {{ DailyDepositSchemeProduct.lastInterestDate | dateFormat }} +
+ } +
+
+ + +
+
+
+

{{ 'labels.inputs.Settings' | translate }}

+
+ +

Premature Option

+
+
+ Loan Facility + + + {{ DailyDepositSchemeProduct.loanFacility | yesNo }} + + +
+
+ Premature Withdrawal Option + + + {{ DailyDepositSchemeProduct.prematureWithdrawalOption | yesNo }} + + +
+
+ Tenure Flexibility + + + {{ DailyDepositSchemeProduct.tenureFlexibility | yesNo }} + + +
+
+ Lien Marked + + + {{ DailyDepositSchemeProduct.lienMarked | yesNo }} + + +
+
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }} + + + {{ DailyDepositSchemeProduct.withdrawalFeeForTransfers | yesNo }} + + +
+
+ +

{{ 'labels.inputs.Lock-in Period' | translate }}

+
+
+ Enable Lock-in Period + + + {{ DailyDepositSchemeProduct.enableLockinPeriod | yesNo }} + + +
+ @if (DailyDepositSchemeProduct.lockinPeriodFrequency) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }} + {{ DailyDepositSchemeProduct.lockinPeriodFrequency }} + {{ DailyDepositSchemeProduct.lockinPeriodFrequencyType?.value }} +
+ } +
+ +

Maturity Instructions

+
+
+ Maturity Instructions + + + {{ DailyDepositSchemeProduct.maturityInstructions | yesNo }} + + +
+
+ Credit Maturity Proceeds + + + {{ DailyDepositSchemeProduct.creditMaturityProceeds | yesNo }} + + +
+
+ Penalty Grace Period (Missed Installments) + + + {{ DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments | yesNo }} + + +
+
+ Panel Request Applicable + + + {{ DailyDepositSchemeProduct.panelRequestApplicable | yesNo }} + + +
+
+ No Penalty (Nominee / Heir Closure) + + + {{ DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure | yesNo }} + + +
+
+ Deceased Settlement + + + {{ DailyDepositSchemeProduct.deceasedSettlement | yesNo }} + + +
+
+ Allow Debit Txn After Due Date + + + {{ DailyDepositSchemeProduct.allowDebitTxnAfterDueDate | yesNo }} + + +
+
+ +

Additional Rules

+
+
+ Account Closed Zero Balance in Day Closing + + + {{ DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing | yesNo }} + + +
+ @if (DailyDepositSchemeProduct.minBalanceForInterestCalculation) { +
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }} + {{ DailyDepositSchemeProduct.minBalanceForInterestCalculation | formatNumber }} +
+ } + @if (DailyDepositSchemeProduct.minDepositTerm) { +
+ {{ 'labels.inputs.Minimum Deposit Term' | translate }} + {{ DailyDepositSchemeProduct.minDepositTerm }} + {{ DailyDepositSchemeProduct.minDepositTermType?.value }} +
+ } + @if (DailyDepositSchemeProduct.maxDepositTerm) { +
+ {{ 'labels.inputs.Maximum Deposit Term' | translate }} + {{ DailyDepositSchemeProduct.maxDepositTerm }} + {{ DailyDepositSchemeProduct.maxDepositTermType?.value }} +
+ } + @if (DailyDepositSchemeProduct.inMultipleOfDepositTerm) { +
+ {{ 'labels.inputs.Deposit Term In multiple of' | translate }} + {{ DailyDepositSchemeProduct.inMultipleOfDepositTerm }} - + {{ DailyDepositSchemeProduct.inMultipleOfDepositTermType?.value }} +
+ } + @if (DailyDepositSchemeProduct.isMandatoryDeposit !== undefined) { +
+ {{ 'labels.inputs.Daily Deposit Scheme Type' | translate }} + {{ + DailyDepositSchemeProduct.isMandatoryDeposit + ? ('labels.text.Mandatory' | translate) + : ('labels.text.Voluntary' | translate) + }} +
+ } + @if (DailyDepositSchemeProduct.preClosurePenalInterest) { +
+ {{ 'labels.inputs.Apply penal interest (less)' | translate }} + {{ DailyDepositSchemeProduct.preClosurePenalInterest }}% On + {{ DailyDepositSchemeProduct.preClosurePenalInterestOnType?.value }} +
+ } +
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }} + + + {{ DailyDepositSchemeProduct.withHoldTax | yesNo }} + + +
+ @if (DailyDepositSchemeProduct.withHoldTax) { +
+ {{ 'labels.inputs.Tax Group' | translate }} + {{ DailyDepositSchemeProduct.taxGroup?.name }} +
+ } +
+
+ + + @if (DailyDepositSchemeProduct.activeChart) { +
+
+
+

{{ 'labels.heading.Interest Rate Charts' | translate }}

+
+ + +
+ @if (DailyDepositSchemeProduct.activeChart.name) { + {{ DailyDepositSchemeProduct.activeChart.name }} + } + {{ DailyDepositSchemeProduct.activeChart.fromDate | dateFormat }} + @if (DailyDepositSchemeProduct.activeChart.endDate) { + + {{ DailyDepositSchemeProduct.activeChart.endDate | dateFormat }} + } + + Primary by Amount: {{ DailyDepositSchemeProduct.activeChart.isPrimaryGroupingByAmount | yesNo }} + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sr.{{ i + 1 }}Particular{{ chartSlab.description }}From{{ chartSlab.fromPeriod }}To{{ chartSlab.toPeriod ?? '—' }}Period{{ chartSlab.periodType?.value }}Rate (%) + {{ chartSlab.annualInterestRate }} + + + +
+

{{ 'labels.heading.Incentives' | translate }}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Entity Type' | translate }} + {{ + incentive.entityType + | find: DailyDepositSchemeProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value' + }} + {{ 'labels.inputs.Attribute Name' | translate }} + {{ + incentive.attributeName + | find + : DailyDepositSchemeProductTemplate.chartTemplate.attributeNameOptions + : 'id' + : 'value' + }} + {{ 'labels.inputs.Condition Type' | translate }} + {{ + incentive.conditionType + | find + : DailyDepositSchemeProductTemplate.chartTemplate.conditionTypeOptions + : 'id' + : 'value' + }} + {{ 'labels.inputs.Attribute Value' | translate }} + @switch (incentive.attributeName) { + @case (2) { + {{ + incentive.attributeValue + | find: DailyDepositSchemeProductTemplate.chartTemplate.genderOptions : 'id' : 'name' + }} + } + @case (3) { + {{ incentive.attributeValue }} + } + @case (4) { + {{ + incentive.attributeValue + | find + : DailyDepositSchemeProductTemplate.chartTemplate.clientTypeOptions + : 'id' + : 'name' + }} + } + @case (5) { + {{ + incentive.attributeValue + | find + : DailyDepositSchemeProductTemplate.chartTemplate.clientClassificationOptions + : 'id' + : 'name' + }} + } + } + {{ 'labels.inputs.Incentive Type' | translate }} + {{ + incentive.incentiveType + | find + : DailyDepositSchemeProductTemplate.chartTemplate.incentiveTypeOptions + : 'id' + : 'value' + }} + {{ 'labels.inputs.Amount' | translate }}{{ incentive.amount }}
+
+
+
+
+
+ } + + + @if (DailyDepositSchemeProduct.charges.length) { +
+
+
+

{{ 'labels.heading.Charges' | translate }}

+
+
+ + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }}{{ charge.name }}{{ 'labels.inputs.Type' | translate }}{{ charge.chargeCalculationType.value }}{{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value }}
+
+
+ } + + +
+
+
+

{{ 'labels.heading.Accounting' | translate }}

+
+ +
+ + + @if ( + DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length || + DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length || + DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length + ) { +
+
+
+

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

+
+ @if (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length) { +

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

+
+ + + + + + + + + + + +
{{ 'labels.inputs.Payment Type' | translate }}{{ p.paymentType.name }}{{ 'labels.inputs.Fund Source' | translate }}{{ p.fundSourceAccount.name }}
+
+ } + @if (DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length) { +

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

+
+ + + + + + + + + + + +
{{ 'labels.inputs.Fee/Charge' | translate }}{{ f.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ f.incomeAccount.name }}
+
+ } + @if (DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) { +

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

+
+ + + + + + + + + + + +
{{ 'labels.inputs.Penalty' | translate }}{{ p.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ p.incomeAccount.name }}
+
+ } +
+ } +
diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss new file mode 100644 index 000000000..9e7b167c3 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss @@ -0,0 +1,528 @@ +/** + * 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/. + */ + +:host { + --vsp-navy: #0b1e3d; + --vsp-blue: #1d4ed8; + --vsp-blue-l: #eff6ff; + --vsp-teal: #0e7e6b; + --vsp-teal-l: #ccfbf1; + --vsp-purple: #6d28d9; + --vsp-purple-l: #ede9fe; + --vsp-indigo: #3730a3; + --vsp-indigo-l: #e0e7ff; + --vsp-amber: #b45309; + --vsp-amber-l: #fef3c7; + --vsp-green: #15803d; + --vsp-green-l: #dcfce7; + --vsp-red: #c0392b; + --vsp-red-l: #fee2e2; + --vsp-pink: #be185d; + --vsp-pink-l: #fce7f3; + --vsp-g50: #f8fafc; + --vsp-g100: #f1f5f9; + --vsp-g200: #e2e8f0; + --vsp-g500: #64748b; + --vsp-g700: #334155; + --vsp-g900: #0f172a; + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--vsp-g900); + font-size: 13.5px; +} + +.vsp-root { + padding: 4px 4px 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.vsp-hero { + background: linear-gradient(135deg, var(--vsp-navy) 0%, var(--vsp-blue) 100%); + border-radius: 14px; + padding: 22px 22px 22px 18px; + display: flex; + align-items: flex-start; + gap: 18px; + box-shadow: + 0 10px 24px rgb(11 30 61 / 18%), + 0 4px 10px rgb(11 30 61 / 12%); + color: #fff; + position: relative; + overflow: hidden; +} + +.vsp-hero::before { + content: ''; + position: absolute; + top: -60px; + right: -60px; + width: 240px; + height: 240px; + border-radius: 50%; + background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%); + pointer-events: none; +} + +.vsp-hero-left { + display: flex; + align-items: flex-start; + gap: 16px; + flex: 1; + min-width: 0; +} + +.vsp-hero-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 22%); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 24px; + flex-shrink: 0; +} + +.vsp-hero-text { + flex: 1; + min-width: 0; +} + +.vsp-hero-eyebrow { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgb(255 255 255 / 70%); + margin-bottom: 4px; +} + +.vsp-hero-title { + margin: 0 0 10px; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.01em; + color: #fff; + line-height: 1.2; +} + +.vsp-hero-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.vsp-hero-desc { + margin: 12px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 82%); + line-height: 1.5; + max-width: 720px; +} + +.vsp-edit-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border-radius: 10px; + background: rgb(255 255 255 / 14%); + color: #fff; + border: 1px solid rgb(255 255 255 / 28%); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + font-family: inherit; + flex-shrink: 0; + transition: all 0.16s ease; + + fa-icon { + font-size: 12px; + } + + &:hover { + background: rgb(255 255 255 / 24%); + border-color: rgb(255 255 255 / 50%); + } +} + +.vsp-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + font-family: inherit; + + fa-icon { + font-size: 9.5px; + } + + &--code { + background: rgb(255 255 255 / 18%); + color: #fff; + border: 1px solid rgb(255 255 255 / 24%); + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + } + + &--teal { + background: var(--vsp-teal-l); + color: var(--vsp-teal); + border: 1px solid #99f6e4; + } + + &--blue { + background: var(--vsp-blue-l); + color: var(--vsp-blue); + border: 1px solid #dbeafe; + } + + &--green { + background: var(--vsp-green-l); + color: var(--vsp-green); + border: 1px solid #bbf7d0; + } + + &--amber { + background: var(--vsp-amber-l); + color: var(--vsp-amber); + border: 1px solid #fde68a; + } + + &--red { + background: var(--vsp-red-l); + color: var(--vsp-red); + border: 1px solid #fecaca; + } + + &--gray { + background: var(--vsp-g100); + color: var(--vsp-g700); + border: 1px solid var(--vsp-g200); + } +} + +.vsp-section { + background: #fff; + border: 1px solid var(--vsp-g200); + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); +} + +.vsp-section-h { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px dashed var(--vsp-g200); + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--vsp-g900); + letter-spacing: 0.02em; + } +} + +.vsp-section-ic { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + + &--blue { + background: var(--vsp-blue-l); + color: var(--vsp-blue); + } + + &--teal { + background: var(--vsp-teal-l); + color: var(--vsp-teal); + } + + &--purple { + background: var(--vsp-purple-l); + color: var(--vsp-purple); + } + + &--indigo { + background: var(--vsp-indigo-l); + color: var(--vsp-indigo); + } + + &--amber { + background: var(--vsp-amber-l); + color: var(--vsp-amber); + } + + &--green { + background: var(--vsp-green-l); + color: var(--vsp-green); + } + + &--red { + background: var(--vsp-red-l); + color: var(--vsp-red); + } + + &--pink { + background: var(--vsp-pink-l); + color: var(--vsp-pink); + } +} + +.vsp-sub-h { + margin: 16px 0 10px; + font-size: 12px; + font-weight: 700; + color: var(--vsp-g700); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.vsp-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 24px; +} + +@media (width <=600px) { + .vsp-grid { + grid-template-columns: 1fr; + } +} + +.vsp-kv { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 0; + border-bottom: 1px dashed var(--vsp-g100); + + &--span { + grid-column: 1 / -1; + } +} + +.vsp-k { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vsp-g500); +} + +.vsp-v { + font-size: 13.5px; + color: var(--vsp-g900); + font-weight: 500; + word-break: break-word; + + strong { + font-weight: 700; + } +} + +.vsp-v--accent { + font-size: 18px; + font-weight: 700; + color: var(--vsp-blue); + letter-spacing: 0.01em; +} + +.vsp-suffix { + font-size: 12px; + font-weight: 600; + color: var(--vsp-g500); + margin-left: 4px; +} + +.vsp-table-wrap { + overflow-x: auto; + border: 1px solid var(--vsp-g200); + border-radius: 10px; + margin-top: 4px; +} + +/* ───────── CHART META PILLS ───────── */ +.vsp-chart-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.vsp-chart-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + padding: 5px 12px; + border-radius: 8px; + background: var(--vsp-g100); + border: 1px solid var(--vsp-g200); + color: var(--vsp-g700); + + fa-icon { + font-size: 11px; + color: var(--vsp-g500); + } + + &--green { + background: var(--vsp-green-l); + border-color: #bbf7d0; + color: var(--vsp-green); + } + + &--red { + background: var(--vsp-red-l); + border-color: #fecaca; + color: var(--vsp-red); + } +} + +/* ───────── RATE BADGE ───────── */ +.vsp-rate-badge { + display: inline-block; + font-size: 13px; + font-weight: 700; + color: var(--vsp-blue); + background: var(--vsp-blue-l); + border: 1px solid #dbeafe; + border-radius: 6px; + padding: 3px 10px; + letter-spacing: 0.02em; +} + +/* ───────── CELL SUB TEXT ───────── */ +.vsp-cell-sub { + display: block; + font-size: 10.5px; + color: var(--vsp-g500); + margin-top: 2px; +} + +/* ───────── INCENTIVE EXPAND ───────── */ +.vsp-incentive-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + font-weight: 600; + padding: 5px 12px; + border-radius: 8px; + border: 1px solid var(--vsp-g200); + background: var(--vsp-g50); + color: var(--vsp-blue); + cursor: pointer; + font-family: inherit; + transition: all 0.14s ease; + + fa-icon { + font-size: 11px; + } + + &:hover { + background: var(--vsp-blue-l); + border-color: #dbeafe; + } +} + +.vsp-incentive-cell { + padding: 0 !important; +} + +.vsp-incentive-panel { + overflow: hidden; + padding: 0 14px; + background: var(--vsp-g50); + border-top: 1px dashed var(--vsp-g200); +} + +.vsp-incentive-title { + margin: 12px 0 8px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vsp-g500); +} + +.vsp-table { + width: 100%; + background: #fff; + + th.mat-mdc-header-cell { + background: var(--vsp-g50); + color: var(--vsp-g700); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 11px 14px; + border-bottom: 1px solid var(--vsp-g200); + white-space: nowrap; + } + + td.mat-mdc-cell { + padding: 10px 14px; + border-bottom: 1px solid var(--vsp-g100); + font-size: 12.5px; + color: var(--vsp-g900); + } + + tbody tr:last-child td.mat-mdc-cell { + border-bottom: 0; + } + + tbody tr:not(.incentives-row, .vsp-row-even) td.mat-mdc-cell { + background: #fff !important; + } + + tbody tr.vsp-row-even td.mat-mdc-cell { + background: var(--vsp-g50) !important; + } + + tr.incentives-row { + height: 0; + + > td { + padding: 0; + } + + .incentives { + overflow: hidden; + } + } +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts new file mode 100644 index 000000000..ca0a551e7 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts @@ -0,0 +1,121 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { trigger, state, transition, animate, style } from '@angular/animations'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDivider } from '@angular/material/divider'; + +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component'; +import { FindPipe } from '../../../../pipes/find.pipe'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; +import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; +import { YesnoPipe } from '../../../../pipes/yesno.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-daily-deposit-scheme-general-tab', + templateUrl: './daily-deposit-scheme-general-tab.component.html', + styleUrls: ['./daily-deposit-scheme-general-tab.component.scss'], + animations: [ + trigger('expandChartSlab', [ + state('collapsed', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) + ]) + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + FaIconComponent, + MatDivider, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + ViewSavingsAccountingDetailsComponent, + FindPipe, + DateFormatPipe, + FormatNumberPipe, + YesnoPipe + ] +}) +export class DailyDepositSchemeGeneralTabComponent { + private route = inject(ActivatedRoute); + + DailyDepositSchemeProduct: any; + DailyDepositSchemeProductTemplate: any; + expandChartSlabIndex: number | null = null; + + chartSlabsIncentivesDisplayedColumns: string[] = ['incentives']; + readonly chartSlabsBaseColumns = [ + 'srNo', + 'description', + 'fromPeriod', + 'toPeriod', + 'periodTypeLabel', + 'annualInterestRate' + ]; + + get chartSlabsDisplayedColumns(): string[] { + const slabs = this.DailyDepositSchemeProduct?.activeChart?.chartSlabs || []; + const hasIncentives = slabs.some((s: any) => s.incentives?.length); + return hasIncentives ? [ + ...this.chartSlabsBaseColumns, + 'actions' + ] : this.chartSlabsBaseColumns; + } + incentivesDisplayedColumns: string[] = [ + 'entityType', + 'attributeName', + 'conditionType', + 'attributeValue', + 'incentiveType', + 'amount' + ]; + chargesDisplayedColumns: string[] = [ + 'name', + 'type', + 'amount', + 'collectedon' + ]; + paymentFundSourceDisplayedColumns: string[] = [ + 'paymentTypeId', + 'fundSourceAccountId' + ]; + feesPenaltyIncomeDisplayedColumns: string[] = [ + 'chargeId', + 'incomeAccountId' + ]; + + constructor() { + this.route.data.subscribe((data: { DailyDepositSchemeProduct: any; DailyDepositSchemeProductsTemplate: any }) => { + this.DailyDepositSchemeProduct = data.DailyDepositSchemeProduct; + this.DailyDepositSchemeProductTemplate = data.DailyDepositSchemeProductsTemplate; + }); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html new file mode 100644 index 000000000..2295a36d2 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html @@ -0,0 +1,68 @@ + + +
+ @if (!rdInterestRates || rdInterestRates.length === 0) { +

No CBS base interest rate versions defined.

+ } + + + @for (version of rdInterestRates; track version.id) { + + + + + {{ version.versionLabel }} + + {{ version.isActive ? 'Active' : 'Inactive' }} + + Effective from: {{ version.effectiveFromDate }} + + + + + @if (version.remarks) { +

+ {{ version.remarks }} +

+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? '—' }}Period Type{{ slab.periodTypeLabel }}Annual Rate (%) + {{ slab.annualInterestRate | number: '1.2-2' }} +
+
+ } +
+
diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss new file mode 100644 index 000000000..1728940c9 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss @@ -0,0 +1,40 @@ +/** + * 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/. + */ + +.rd-version-header { + display: flex; + align-items: center; + gap: 12px; +} + +.rd-active-badge { + background: #4caf50; + color: #fff; + border-radius: 4px; + padding: 2px 8px; + font-size: 12px; +} + +.rd-inactive-badge { + background: #9e9e9e; + color: #fff; + border-radius: 4px; + padding: 2px 8px; + font-size: 12px; +} + +.rd-slab-table { + width: 100%; + margin-top: 8px; +} + +.no-rates { + padding: 24px; + color: #757575; + text-align: center; +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts new file mode 100644 index 000000000..ef3ce4220 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts @@ -0,0 +1,66 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { DecimalPipe } from '@angular/common'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-rd-interest-rates-tab', + templateUrl: './rd-interest-rates-tab.component.html', + styleUrls: ['./rd-interest-rates-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatExpansionModule, + DecimalPipe + ] +}) +export class DailyDepositSchemeInterestRatesTabComponent { + private route = inject(ActivatedRoute); + + rdInterestRates: any[] = []; + + readonly slabColumns = [ + 'srNo', + 'description', + 'fromPeriod', + 'toPeriod', + 'periodTypeLabel', + 'annualInterestRate' + ]; + + constructor() { + this.route.data.subscribe((data: { rdInterestRates: any[] }) => { + this.rdInterestRates = data.rdInterestRates || []; + }); + } +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts new file mode 100644 index 000000000..68e82a666 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts @@ -0,0 +1,16 @@ +/** + * 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 { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; +import { ProductsService } from '../../../products.service'; + +export const rdInterestRatesResolver: ResolveFn = (route) => { + const productsService = inject(ProductsService); + return productsService.getRdBaseInterestRates(); +}; diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html new file mode 100644 index 000000000..041cff962 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html @@ -0,0 +1,39 @@ + + + + +
+ + + + + + diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss new file mode 100644 index 000000000..74229104a --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss @@ -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/. + */ + +.product-card { + margin: 0 auto; + max-width: 80rem; + width: 90%; + padding: 0; + + .navigation-tabs { + overflow: auto; + } + + i:hover { + cursor: pointer; + } +} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts new file mode 100644 index 000000000..5e871f560 --- /dev/null +++ b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts @@ -0,0 +1,44 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, RouterLinkActive, RouterLink, RouterOutlet, Router } from '@angular/router'; +import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-view-daily-deposit-scheme-product', + templateUrl: './view-daily-deposit-scheme-product.component.html', + styleUrls: ['./view-daily-deposit-scheme-product.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabNav, + MatTabLink, + RouterLinkActive, + RouterLink, + MatTabNavPanel, + RouterOutlet + ] +}) +export class ViewDailyDepositSchemeProductComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + + recurringDepositDatatables: any = []; + + constructor() { + this.route.data.subscribe((data: { recurringDepositDatatables: any }) => { + this.recurringDepositDatatables = []; + data.recurringDepositDatatables.forEach((datatable: any) => { + if (datatable.entitySubType === 'Daily Deposit Scheme') { + this.recurringDepositDatatables.push(datatable); + } + }); + }); + } +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index 034086845..9c0111bef 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -30,6 +30,12 @@ import { EditShareProductComponent } from './share-products/edit-share-product/e import { ManageTaxConfigurationsComponent } from './manage-tax-configurations/manage-tax-configurations.component'; import { RecurringDepositProductsComponent } from './recurring-deposit-products/recurring-deposit-products.component'; import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component'; +import { DailyDepositSchemeProductsComponent } from './daily-deposit-scheme-products/daily-deposit-scheme-products.component'; +import { CreateDailyDepositSchemeProductComponent } from './daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component'; +import { ViewDailyDepositSchemeProductComponent } from './daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component'; +import { DailyDepositSchemeGeneralTabComponent } from './daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component'; +import { DailyDepositSchemeDatatableTabComponent } from './daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component'; +import { EditDailyDepositSchemeProductComponent } from './daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component'; import { ChargesComponent } from './charges/charges.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; @@ -75,6 +81,10 @@ import { ShareProductResolver } from './share-products/share-product-resolver'; import { ShareProductAndTemplateResolver } from './share-products/edit-share-product/share-product-and-template.resolver'; import { RecurringDepositProductsResolver } from './recurring-deposit-products/recurring-deposit-products.resolver'; import { RecurringDepositProductResolver } from './recurring-deposit-products/recurring-deposit-product.resolver'; +import { DailyDepositSchemeProductsResolver } from './daily-deposit-scheme-products/daily-deposit-scheme-products.resolver'; +import { DailyDepositSchemeProductsTemplateResolver } from './daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver'; +import { DailyDepositSchemeProductResolver } from './daily-deposit-scheme-products/daily-deposit-scheme-product.resolver'; +import { DailyDepositSchemeProductAndTemplateResolver } from './daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver'; import { ChargesResolver } from './charges/charges.resolver'; import { ChargeResolver } from './charges/charge.resolver'; import { ChargesTemplateResolver } from './charges/charges-template.resolver'; @@ -724,6 +734,83 @@ const routes: Routes = [ } ] }, + { + path: 'daily-deposit-scheme-products', + data: { title: 'Daily Deposit Scheme Products', breadcrumb: 'Daily Deposit Scheme Products' }, + children: [ + { + path: 'create', + component: CreateDailyDepositSchemeProductComponent, + data: { title: 'Create Daily Deposit Scheme Product', breadcrumb: 'Create' }, + resolve: { + dailyDepositSchemeProductsTemplate: DailyDepositSchemeProductsTemplateResolver + } + }, + { + path: '', + component: DailyDepositSchemeProductsComponent, + resolve: { + dailyDepositSchemeProducts: DailyDepositSchemeProductsResolver + } + }, + { + path: ':productId', + data: { + title: 'View Daily Deposit Scheme Product', + breadcrumb: 'productId', + routeParamBreadcrumb: 'productId' + }, + resolve: { + recurringDepositDatatables: SavingProductDatatablesResolver + }, + children: [ + { + path: '', + component: ViewDailyDepositSchemeProductComponent, + children: [ + { + path: '', + redirectTo: 'general', + pathMatch: 'full' + }, + { + path: 'general', + component: DailyDepositSchemeGeneralTabComponent, + resolve: { + dailyDepositSchemeProduct: DailyDepositSchemeProductResolver + } + }, + { + path: 'datatables', + children: [ + { + path: ':datatableName', + component: DailyDepositSchemeDatatableTabComponent, + data: { title: 'Data Table View', routeParamBreadcrumb: 'datatableName' }, + resolve: { + recurringDepositDatatable: SavingProductDatatableResolver + } + } + ] + } + ] + }, + { + path: 'edit', + data: { + title: 'Edit Daily Deposit Scheme Product', + breadcrumb: 'edit', + routeParamBreadcrumb: false + }, + component: EditDailyDepositSchemeProductComponent, + resolve: { + DailyDepositSchemeProductAndTemplate: DailyDepositSchemeProductAndTemplateResolver + } + } + ] + } + ] + }, { path: 'fixed-deposit-products', data: { title: 'Fixed Deposit Products', breadcrumb: 'Fixed Deposit Products' }, @@ -1004,6 +1091,10 @@ const routes: Routes = [ ShareProductAndTemplateResolver, RecurringDepositProductsResolver, RecurringDepositProductResolver, + DailyDepositSchemeProductsResolver, + DailyDepositSchemeProductsTemplateResolver, + DailyDepositSchemeProductResolver, + DailyDepositSchemeProductAndTemplateResolver, ChargesResolver, ChargeResolver, ChargesTemplateAndResolver, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index 3345cdb55..07d63d8db 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -125,6 +125,25 @@
+
+ +
+ +
+
+

{{ 'labels.heading.Daily Deposit Scheme Products' | translate }}

+

+ {{ 'labels.text.Add, modify or inactivate a Daily Deposit Scheme product' | translate }} +

+
+ +
+
+
From e2eff9914f04de2ac38d69294423fbc65764c7ad Mon Sep 17 00:00:00 2001 From: tarunmalviya Date: Wed, 23 Sep 2026 11:54:49 +0530 Subject: [PATCH 02/21] DDS: agent collection entry screen, per-agent GL account, maturity auto close The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what settles is what the entries say, to the rupee. Agent Master now asks which ledger account the agent's collections pass through, one agent to an account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the agent's own savings list for the same reason the server refuses it: debits are blocked on those accounts, so commission paid into one would go in and never come out. The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest Post question directly above it, and Batch Processing offers the run that applies it. Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that holds a word, and an agent's birth date cannot be picked later than eighteen years ago. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 5437 +---------------- src/app/accounts/accounts-routing.module.ts | 24 +- src/app/core/shell/shell.component.html | 6 +- .../daily-transaction-entry.component.html | 191 +- .../daily-transaction-entry.component.scss | 214 +- .../daily-transaction-entry.component.ts | 621 +- .../day-collection-dialog.component.html | 58 + .../day-collection-dialog.component.scss | 42 + .../day-collection-dialog.component.ts | 117 + .../notice-dialog.component.html | 21 + .../notice-dialog.component.scss | 44 + .../notice-dialog/notice-dialog.component.ts | 39 + .../report-viewer.component.html | 144 +- .../report-viewer.component.scss | 87 + .../report-viewer/report-viewer.component.ts | 262 +- .../party-master/CBS-Backend.code-workspace | 11 + .../create-party/create-party.component.html | 975 +++ .../create-party/create-party.component.scss | 526 ++ .../create-party/create-party.component.ts | 1764 ++++++ .../due-for-closure.component.html | 163 + .../due-for-closure.component.scss | 97 + .../due-for-closure.component.ts | 194 + .../nominee-list-dialog.component.html | 73 + .../nominee-list-dialog.component.scss | 84 + .../nominee-list-dialog.component.ts | 82 + .../party-master/party-master.component.html | 739 +-- .../party-master/party-master.component.scss | 257 +- .../party-master/party-master.component.ts | 620 +- .../payout-dialog.component.html | 79 + .../payout-dialog.component.scss | 92 + .../payout-dialog/payout-dialog.component.ts | 109 + .../premature-close-dialog.component.html | 118 + .../premature-close-dialog.component.scss | 112 + .../premature-close-dialog.component.ts | 136 + src/app/directives/directives.module.ts | 7 +- .../directives/restrict-input.directive.ts | 137 + .../agent-master/agent-master.component.html | 495 +- .../agent-master/agent-master.component.scss | 156 +- .../agent-master/agent-master.component.ts | 449 +- .../create-agent/create-agent.component.html | 253 + .../create-agent/create-agent.component.scss | 146 + .../create-agent/create-agent.component.ts | 593 ++ .../commission-slab-master.component.html | 96 +- .../commission-slab-master.component.scss | 54 +- .../commission-slab-master.component.ts | 185 +- .../dds-batch-processing.component.html | 245 +- .../dds-batch-processing.component.scss | 118 +- .../dds-batch-processing.component.ts | 322 +- .../dds-batch-processing/dds-date-adapter.ts | 71 + .../organization-routing.module.ts | 20 +- src/app/organization/organization.service.ts | 133 + ...aily-deposit-scheme-product.component.html | 131 - ...aily-deposit-scheme-product.component.scss | 138 - ...-daily-deposit-scheme-product.component.ts | 346 -- ...posit-scheme-products-template.resolver.ts | 35 - ...eme-product-accounting-step.component.html | 121 - ...eme-product-accounting-step.component.scss | 303 - ...cheme-product-accounting-step.component.ts | 423 -- ...scheme-product-charges-step.component.html | 85 - ...scheme-product-charges-step.component.scss | 69 - ...t-scheme-product-charges-step.component.ts | 103 - ...cheme-product-currency-step.component.html | 89 - ...cheme-product-currency-step.component.scss | 102 - ...-scheme-product-currency-step.component.ts | 104 - ...scheme-product-details-step.component.html | 77 - ...scheme-product-details-step.component.scss | 81 - ...t-scheme-product-details-step.component.ts | 177 - ...ct-interest-rate-chart-step.component.html | 109 - ...ct-interest-rate-chart-step.component.scss | 215 - ...duct-interest-rate-chart-step.component.ts | 717 --- ...scheme-product-preview-step.component.html | 732 --- ...scheme-product-preview-step.component.scss | 131 - ...t-scheme-product-preview-step.component.ts | 142 - ...cheme-product-settings-step.component.html | 123 - ...cheme-product-settings-step.component.scss | 164 - ...-scheme-product-settings-step.component.ts | 95 - ...t-scheme-product-terms-step.component.html | 192 - ...t-scheme-product-terms-step.component.scss | 151 - ...sit-scheme-product-terms-step.component.ts | 169 - .../daily-deposit-scheme-product.resolver.ts | 37 - ...ily-deposit-scheme-products.component.html | 172 - ...ily-deposit-scheme-products.component.scss | 17 - ...daily-deposit-scheme-products.component.ts | 124 - .../daily-deposit-scheme-products.resolver.ts | 35 - ...it-scheme-product-and-template.resolver.ts | 35 - ...aily-deposit-scheme-product.component.html | 202 - ...aily-deposit-scheme-product.component.scss | 138 - ...-daily-deposit-scheme-product.component.ts | 305 - ...eposit-scheme-datatable-tab.component.html | 17 - ...eposit-scheme-datatable-tab.component.scss | 7 - ...-deposit-scheme-datatable-tab.component.ts | 38 - ...-deposit-scheme-general-tab.component.html | 843 --- ...-deposit-scheme-general-tab.component.scss | 528 -- ...ly-deposit-scheme-general-tab.component.ts | 121 - ...t-scheme-interest-rates-tab.component.html | 68 - ...t-scheme-interest-rates-tab.component.scss | 40 - ...sit-scheme-interest-rates-tab.component.ts | 66 - ...-deposit-scheme-interest-rates.resolver.ts | 16 - ...aily-deposit-scheme-product.component.html | 39 - ...aily-deposit-scheme-product.component.scss | 22 - ...-daily-deposit-scheme-product.component.ts | 44 - .../create-dds-account-type.component.html | 126 + .../create-dds-account-type.component.scss | 112 + .../create-dds-account-type.component.ts | 210 + .../dds-account-types.component.html | 141 + .../dds-account-types.component.scss | 39 + .../dds-account-types.component.ts | 112 + src/app/products/products-routing.module.ts | 95 +- src/app/products/products.component.html | 14 +- src/app/products/products.service.ts | 37 + .../global-configurations-tab.component.html | 13 +- .../global-configurations-tab.component.scss | 11 + .../global-configurations-tab.component.ts | 44 + src/environments/.env.ts | 4 +- 114 files changed, 10259 insertions(+), 15955 deletions(-) create mode 100644 src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html create mode 100644 src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss create mode 100644 src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts create mode 100644 src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html create mode 100644 src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss create mode 100644 src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts create mode 100644 src/app/deposits/party-master/CBS-Backend.code-workspace create mode 100644 src/app/deposits/party-master/create-party/create-party.component.html create mode 100644 src/app/deposits/party-master/create-party/create-party.component.scss create mode 100644 src/app/deposits/party-master/create-party/create-party.component.ts create mode 100644 src/app/deposits/party-master/due-for-closure/due-for-closure.component.html create mode 100644 src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss create mode 100644 src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts create mode 100644 src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html create mode 100644 src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss create mode 100644 src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts create mode 100644 src/app/deposits/party-master/payout-dialog/payout-dialog.component.html create mode 100644 src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss create mode 100644 src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts create mode 100644 src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html create mode 100644 src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss create mode 100644 src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts create mode 100644 src/app/directives/restrict-input.directive.ts create mode 100644 src/app/organization/agent-master/create-agent/create-agent.component.html create mode 100644 src/app/organization/agent-master/create-agent/create-agent.component.scss create mode 100644 src/app/organization/agent-master/create-agent/create-agent.component.ts create mode 100644 src/app/organization/dds-batch-processing/dds-date-adapter.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss delete mode 100644 src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts create mode 100644 src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html create mode 100644 src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss create mode 100644 src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts create mode 100644 src/app/products/dds-account-types/dds-account-types.component.html create mode 100644 src/app/products/dds-account-types/dds-account-types.component.scss create mode 100644 src/app/products/dds-account-types/dds-account-types.component.ts diff --git a/package-lock.json b/package-lock.json index 592a9c0cf..b3f19e3ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -319,6 +319,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -429,218 +430,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/build-angular": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-20.3.15.tgz", - "integrity": "sha512-md5ikmdH0rM4jkVFwNQhN++onUUZPUDUfKijnb1XZArQisT9hPiNqTPbO+OZTBK/hQYYV/GfTOhDH30whG32xQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2003.15", - "@angular-devkit/build-webpack": "0.2003.15", - "@angular-devkit/core": "20.3.15", - "@angular/build": "20.3.15", - "@babel/core": "7.28.3", - "@babel/generator": "7.28.3", - "@babel/helper-annotate-as-pure": "7.27.3", - "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-transform-async-generator-functions": "7.28.0", - "@babel/plugin-transform-async-to-generator": "7.27.1", - "@babel/plugin-transform-runtime": "7.28.3", - "@babel/preset-env": "7.28.3", - "@babel/runtime": "7.28.3", - "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "20.3.15", - "ansi-colors": "4.1.3", - "autoprefixer": "10.4.21", - "babel-loader": "10.0.0", - "browserslist": "^4.21.5", - "copy-webpack-plugin": "13.0.1", - "css-loader": "7.1.2", - "esbuild-wasm": "0.25.9", - "fast-glob": "3.3.3", - "http-proxy-middleware": "3.0.5", - "istanbul-lib-instrument": "6.0.3", - "jsonc-parser": "3.3.1", - "karma-source-map-support": "1.4.0", - "less": "4.4.0", - "less-loader": "12.3.0", - "license-webpack-plugin": "4.0.2", - "loader-utils": "3.3.1", - "mini-css-extract-plugin": "2.9.4", - "open": "10.2.0", - "ora": "8.2.0", - "picomatch": "4.0.3", - "piscina": "5.1.3", - "postcss": "8.5.6", - "postcss-loader": "8.1.1", - "resolve-url-loader": "5.0.0", - "rxjs": "7.8.2", - "sass": "1.90.0", - "sass-loader": "16.0.5", - "semver": "7.7.2", - "source-map-loader": "5.0.0", - "source-map-support": "0.5.21", - "terser": "5.43.1", - "tree-kill": "1.2.2", - "tslib": "2.8.1", - "webpack": "5.104.1", - "webpack-dev-middleware": "7.4.2", - "webpack-dev-server": "5.2.2", - "webpack-merge": "6.0.1", - "webpack-subresource-integrity": "5.1.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "esbuild": "0.25.9" - }, - "peerDependencies": { - "@angular/compiler-cli": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/localize": "^20.0.0", - "@angular/platform-browser": "^20.0.0", - "@angular/platform-server": "^20.0.0", - "@angular/service-worker": "^20.0.0", - "@angular/ssr": "^20.3.15", - "@web/test-runner": "^0.20.0", - "browser-sync": "^3.0.2", - "jest": "^29.5.0 || ^30.2.0", - "jest-environment-jsdom": "^29.5.0 || ^30.2.0", - "karma": "^6.3.0", - "ng-packagr": "^20.0.0", - "protractor": "^7.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "typescript": ">=5.8 <6.0" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - }, - "@angular/localize": { - "optional": true - }, - "@angular/platform-browser": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "@web/test-runner": { - "optional": true - }, - "browser-sync": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-environment-jsdom": { - "optional": true - }, - "karma": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "protractor": { - "optional": true - }, - "tailwindcss": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/esbuild-wasm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.9.tgz", - "integrity": "sha512-Jpv5tCSwQg18aCqCRD3oHIX/prBhXMDapIoG//A+6+dV0e7KQMGFg85ihJ5T1EeMjbZjON3TqFy0VrGAnIHLDA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@angular-devkit/build-webpack": { - "version": "0.2003.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2003.15.tgz", - "integrity": "sha512-u1gjLH0T+s4PiwbSUhzYZUKCsIF9CvTGJoN+Ki+CQqZAlTGLOp2y55VXmdrRX4e3tsKkS+chpQ8sN2b5vbzv9w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@angular-devkit/architect": "0.2003.15", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "webpack": "^5.30.0", - "webpack-dev-server": "^5.0.2" - } - }, "node_modules/@angular-devkit/core": { "version": "20.3.15", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.15.tgz", @@ -1029,6 +818,7 @@ "version": "20.3.16", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -1446,110 +1236,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -1559,21 +1245,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", @@ -1604,20 +1275,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", @@ -1628,59 +1285,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", @@ -1721,22 +1325,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helpers": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", @@ -1765,109 +1353,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -1923,23 +1408,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", @@ -2124,1077 +1592,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", - "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz", - "integrity": "sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.3.tgz", - "integrity": "sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.27.1", - "@babel/plugin-syntax-import-attributes": "^7.27.1", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.0", - "@babel/plugin-transform-async-to-generator": "^7.27.1", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.0", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.28.3", - "@babel/plugin-transform-classes": "^7.28.3", - "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-dotall-regex": "^7.27.1", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.0", - "@babel/plugin-transform-exponentiation-operator": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.27.1", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-modules-systemjs": "^7.27.1", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.28.0", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.27.1", - "@babel/plugin-transform-private-property-in-object": "^7.27.1", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.3", - "@babel/plugin-transform-regexp-modifiers": "^7.27.1", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.27.1", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.27.1", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -3539,17 +1936,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.17.0" - } - }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", @@ -5379,18 +3765,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -5407,457 +3781,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/buffers": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.65.0.tgz", - "integrity": "sha512-eBrIXd0/Ld3p9lpDDlMaMn6IEfWqtHMD+z61u0JrIiPzsV1r7m6xDZFRxJyvIFTEO+SWdYF9EiQbXZGd8BzPfA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/codegen": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", - "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-core": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.56.10.tgz", - "integrity": "sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.56.10.tgz", - "integrity": "sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.56.10.tgz", - "integrity": "sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", - "glob-to-regex.js": "^1.0.0", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.56.10.tgz", - "integrity": "sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.56.10.tgz", - "integrity": "sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.56.10.tgz", - "integrity": "sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.56.10" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-print": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.56.10.tgz", - "integrity": "sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.56.10", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.56.10.tgz", - "integrity": "sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/json-pack": "^17.65.0", - "@jsonjoy.com/util": "^17.65.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.65.0.tgz", - "integrity": "sha512-Xrh7Fm/M0QAYpekSgmskdZYnFdSGnsxJ/tHaolA4bNwWdG9i65S8m83Meh7FOxyJyQAdo4d4J97NOomBLEfkDQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.65.0.tgz", - "integrity": "sha512-7MXcRYe7n3BG+fo3jicvjB0+6ypl2Y/bQp79Sp7KeSiiCgLqw4Oled6chVv07/xLVTdo3qa1CD0VCCnPaw+RGA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.65.0.tgz", - "integrity": "sha512-e0SG/6qUCnVhHa0rjDJHgnXnbsacooHVqQHxspjvlYQSkHm+66wkHw6Gql+3u/WxI/b1VsOdUi0M+fOtkgKGdQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/base64": "17.65.0", - "@jsonjoy.com/buffers": "17.65.0", - "@jsonjoy.com/codegen": "17.65.0", - "@jsonjoy.com/json-pointer": "17.65.0", - "@jsonjoy.com/util": "17.65.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.65.0.tgz", - "integrity": "sha512-uhTe+XhlIZpWOxgPcnO+iSCDgKKBpwkDVTyYiXX9VayGV8HSFVJM67M6pUE71zdnXF1W0Da21AvnhlmdwYPpow==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/util": "17.65.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.65.0.tgz", - "integrity": "sha512-cWiEHZccQORf96q2y6zU3wDeIVPeidmGqd9cNKJRYoVHTV0S1eHPy5JTbHpMnGfDvtvujQwQozOqgO9ABu6h0w==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/buffers": "17.65.0", - "@jsonjoy.com/codegen": "17.65.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.2.0", - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.2", - "@jsonjoy.com/util": "^1.9.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pointer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", - "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/util": "^1.9.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", - "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/buffers": "^1.0.0", - "@jsonjoy.com/codegen": "^1.0.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -5888,14 +3811,6 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz", @@ -6708,24 +4623,6 @@ "node": ">= 10" } }, - "node_modules/@ngtools/webpack": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.15.tgz", - "integrity": "sha512-7bH91SdVN9xawMg4G+OoQG+nFz9/huUXx/MptR5iOsuRUpNo3PQwMNr07VTRV8o4YAtTTRIf2Yam4fNkb3rRAg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "@angular/compiler-cli": "^20.0.0", - "typescript": ">=5.8 <6.0", - "webpack": "^5.54.0" - } - }, "node_modules/@ngx-translate/core": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-16.0.4.tgz", @@ -6756,6 +4653,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -6769,6 +4667,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -6778,6 +4677,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -8183,52 +6083,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, "node_modules/@types/d3-dispatch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", @@ -8294,30 +6148,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/esquery": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@types/esquery/-/esquery-1.5.4.tgz", @@ -8342,34 +6172,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -8380,25 +6182,6 @@ "@types/node": "*" } }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -8513,14 +6296,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/node": { "version": "22.15.29", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz", @@ -8531,31 +6306,12 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/node-forge": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", - "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -8563,22 +6319,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -8586,53 +6326,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -8647,17 +6340,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -8699,17 +6381,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -9082,198 +6753,6 @@ "vite": "^6.0.0 || ^7.0.0" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -9376,20 +6855,6 @@ "acorn-walk": "^8.0.2" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -9413,37 +6878,6 @@ "node": ">=0.4.0" } }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -9503,20 +6937,6 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/algoliasearch": { "version": "5.35.0", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.35.0.tgz", @@ -9582,20 +7002,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "peer": true, - "bin": { - "ansi-html": "bin/ansi-html" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -9624,12 +7030,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -9643,6 +7051,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9792,6 +7201,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -9811,14 +7221,6 @@ "node": ">= 0.4" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -9895,45 +7297,6 @@ "node": ">= 4.0.0" } }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -9997,24 +7360,6 @@ "@babel/core": "^7.8.0" } }, - "node_modules/babel-loader": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", - "integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "find-up": "^5.0.0" - }, - "engines": { - "node": "^18.20.0 || ^20.10.0 || >=22.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5.61.0" - } - }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -10075,62 +7420,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -10325,14 +7614,6 @@ "node": ">=10.0.0" } }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -10363,21 +7644,11 @@ "node": ">=14.0.0" } }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10424,18 +7695,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -10456,6 +7715,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -10568,23 +7828,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -10758,6 +8001,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -10883,6 +8127,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -10904,17 +8149,6 @@ "node": ">=18" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/chromium-bidi": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz", @@ -11188,36 +8422,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clone-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -11367,70 +8571,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -11438,17 +8578,6 @@ "dev": true, "license": "MIT" }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -11477,6 +8606,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -11499,45 +8629,6 @@ "node": ">=6.6.0" } }, - "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-what": "^3.14.1" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/copy-webpack-plugin": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", - "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob-parent": "^6.0.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2", - "tinyglobby": "^0.2.12" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, "node_modules/core-js": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", @@ -11550,21 +8641,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -11761,43 +8837,6 @@ "utrie": "^1.0.2" } }, - "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, "node_modules/css-select": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", @@ -11846,6 +8885,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -12633,52 +9673,6 @@ "node": ">=0.10.0" } }, - "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -12723,18 +9717,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -12756,14 +9738,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/devtools-protocol": { "version": "0.0.1551306", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", @@ -12775,6 +9749,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/diff": { @@ -12814,21 +9789,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -13016,17 +9978,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -13070,21 +10021,6 @@ "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -13142,21 +10078,6 @@ "dev": true, "license": "MIT" }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -13187,14 +10108,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -13965,6 +10878,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -13981,6 +10895,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -14045,25 +10960,12 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -14150,6 +11052,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -14241,28 +11144,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -14316,21 +11197,6 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -14381,6 +11247,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -14395,6 +11262,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14595,6 +11463,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -14603,24 +11472,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -14781,14 +11632,6 @@ "lodash": "^4.17.15" } }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -14877,6 +11720,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -14885,17 +11729,6 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -14926,56 +11759,6 @@ "node": "20 || >=22" } }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -15155,14 +11938,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -15184,30 +11959,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -15236,33 +11987,6 @@ "node": ">= 6.0.0" } }, - "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/http-proxy": "^1.17.15", - "debug": "^4.3.6", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.3", - "is-plain-object": "^5.0.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/http-proxy/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", @@ -15318,17 +12042,6 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.18" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -15346,20 +12059,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -15419,21 +12118,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -15578,6 +12262,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -15590,6 +12275,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -15601,27 +12287,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15654,6 +12324,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -15662,26 +12333,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-installed-globally": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", @@ -15712,24 +12363,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -15745,20 +12383,6 @@ "node": ">=8" } }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", @@ -15815,31 +12439,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -15852,17 +12451,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -16893,6 +13481,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -17267,17 +13856,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "source-map-support": "^0.5.5" - } - }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -17315,18 +13893,6 @@ "dev": true, "license": "MIT" }, - "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" - } - }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", @@ -17379,114 +13945,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/less": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/less/-/less-4.4.0.tgz", - "integrity": "sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" - } - }, - "node_modules/less-loader": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz", - "integrity": "sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "less": "^3.5.0 || ^4.0.0", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -17511,25 +13969,6 @@ "node": ">= 0.8.0" } }, - "node_modules/license-webpack-plugin": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", - "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "webpack-sources": "^3.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-sources": { - "optional": true - } - } - }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -17552,6 +13991,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -17564,6 +14004,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/lint-staged": { @@ -18027,32 +14468,6 @@ "win32" ] }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/loader-utils": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", - "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 12.13.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -18075,14 +14490,6 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -18513,37 +14920,6 @@ "node": ">= 0.8" } }, - "node_modules/memfs": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.10.tgz", - "integrity": "sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-to-fsa": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", - "@jsonjoy.com/json-pack": "^1.11.0", - "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", - "thingies": "^2.5.0", - "tree-dump": "^1.0.3", - "tslib": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -18581,26 +14957,17 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -18614,6 +14981,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -18622,20 +14990,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -18682,28 +15036,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", - "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -18713,14 +15045,6 @@ "mini-svg-data-uri": "cli.js" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -18978,21 +15302,6 @@ "win32" ] }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -19007,6 +15316,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -19018,6 +15328,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -19039,40 +15350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/needle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", - "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -19179,17 +15456,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", - "dev": true, - "license": "(BSD-3-Clause OR GPL-2.0)", - "peer": true, - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-gyp": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", @@ -19304,17 +15570,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-bundled": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", @@ -19474,6 +15729,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19483,6 +15739,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -19501,14 +15758,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -19522,17 +15771,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -19559,26 +15797,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -19840,36 +16058,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -20035,17 +16223,6 @@ "dev": true, "license": "MIT" }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -20154,6 +16331,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -20260,6 +16438,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -20269,6 +16448,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -20417,6 +16597,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -20445,6 +16626,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -20462,6 +16644,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -20487,6 +16670,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, "funding": [ { "type": "opencollective", @@ -20525,39 +16709,6 @@ } } }, - "node_modules/postcss-loader": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", - "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cosmiconfig": "^9.0.0", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", @@ -20565,77 +16716,11 @@ "dev": true, "license": "MIT" }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/postcss-nested": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -20661,6 +16746,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -20749,6 +16835,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -20998,15 +17085,6 @@ "node": ">=22" } }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -21139,6 +17217,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -21165,17 +17244,6 @@ "performance-now": "^2.1.0" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -21213,6 +17281,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -21257,6 +17326,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -21270,30 +17340,9 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, "license": "Apache-2.0" }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -21301,55 +17350,6 @@ "license": "MIT", "optional": true }, - "node_modules/regex-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", - "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, "node_modules/request-progress": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", @@ -21391,6 +17391,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -21440,51 +17441,6 @@ "node": ">=4" } }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/resolve-url-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve.exports": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", @@ -21530,6 +17486,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -21632,24 +17589,11 @@ "node": ">= 18" } }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -21731,60 +17675,6 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass-loader": { - "version": "16.0.5", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", - "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -21797,73 +17687,11 @@ "node": ">=10" } }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -21926,126 +17754,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-index/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/serve-index/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -22079,20 +17787,6 @@ "dev": true, "license": "ISC" }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -22114,20 +17808,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -22298,19 +17978,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -22355,47 +18022,12 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" - } - }, - "node_modules/source-map-loader/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -22453,40 +18085,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -22962,6 +18560,7 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -22984,6 +18583,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -23039,6 +18639,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -23132,6 +18733,7 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -23169,6 +18771,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -23193,6 +18796,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -23205,6 +18809,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -23217,6 +18822,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -23230,6 +18836,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -23238,21 +18845,6 @@ "node": ">=8.10.0" } }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", @@ -23306,86 +18898,6 @@ "node": ">=18" } }, - "node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -23469,6 +18981,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -23478,6 +18991,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -23486,24 +19000,6 @@ "node": ">=0.8" } }, - "node_modules/thingies": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", - "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" - } - }, "node_modules/throttleit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", @@ -23521,14 +19017,6 @@ "dev": true, "license": "MIT" }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -23591,6 +19079,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -23644,24 +19133,6 @@ "url": "https://github.com/sponsors/chrvadala" } }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -23689,6 +19160,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/ts-jest": { @@ -23995,14 +19467,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/typed-event-target": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/typed-event-target/-/typed-event-target-4.1.0.tgz", @@ -24029,7 +19493,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -24060,54 +19524,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/unique-filename": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", @@ -24247,17 +19663,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", @@ -24486,17 +19891,6 @@ "node": ">=10.13.0" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/weak-lru-cache": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", @@ -24577,664 +19971,6 @@ "node": ">=12" } }, - "node_modules/webpack": { - "version": "5.104.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", - "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.4", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", - "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.6.0", - "mime-types": "^2.1.31", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", - "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/webpack-dev-server/node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/webpack-dev-server/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/webpack-dev-server/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/webpack-dev-server/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server/node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webpack-dev-server/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/webpack-dev-server/node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-dev-server/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-dev-server/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webpack-dev-server/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/webpack-dev-server/node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/webpack-dev-server/node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/webpack-dev-server/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/webpack-merge/node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack-subresource-integrity": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", - "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "typed-assert": "^1.0.8" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", - "webpack": "^5.12.0" - }, - "peerDependenciesMeta": { - "html-webpack-plugin": { - "optional": true - } - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/webpack/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -25301,14 +20037,6 @@ "node": ">= 8" } }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -25409,23 +20137,6 @@ } } }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -25468,7 +20179,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 566c9762f..83efecf30 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -12,6 +12,8 @@ import { Routes, RouterModule } from '@angular/router'; import { Route } from '../core/route/route.service'; import { AccountsListComponent } from './accounts-list/accounts-list.component'; import { PartyMasterComponent } from '../deposits/party-master/party-master.component'; +import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component'; +import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component'; import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component'; import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component'; import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component'; @@ -48,8 +50,26 @@ const routes: Routes = [ }, { path: 'dds', - component: PartyMasterComponent, - data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' } + data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }, + children: [ + { path: '', component: PartyMasterComponent }, + { + path: 'create', + component: CreatePartyComponent, + data: { title: 'Create Party', breadcrumb: 'Create' } + }, + { + // Ahead of the :partyId routes, so "dueforclosure" is not read as a party id. + path: 'dueforclosure', + component: DueForClosureComponent, + data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' } + }, + { + path: ':partyId/edit', + component: CreatePartyComponent, + data: { title: 'Edit Party', breadcrumb: 'Edit' } + } + ] }, { path: 'dds-collection', diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 9a32f5b1f..b91425694 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1048,7 +1048,7 @@ -
@@ -1057,7 +1057,7 @@ - - Date - + + @@ -61,75 +130,61 @@ Tran. Type - @for (type of transactionTypes; track type) { - {{ type }} + @for (type of transactionTypes; track type.label) { + + {{ type.label }}@if (!type.supported) { — not available yet} + } - @if (isPartyTran) { + + @if (isPartyTran && agentInfo.name) {
-

Today's Collections

-
- @if (collections.length === 0) { -

No collection rows added yet.

- } + @if (pendingLoading) { +

Reading this agent's entries...

+ } @else if (pendingEntries.length === 0) { +

+ {{ agentInfo.name }} has written nothing down for this date. Nothing to settle. +

+ } @else { + @for (entry of pendingEntries; track entry.id) { +
+ {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + {{ entry.amount | number: '1.2-2' }} +
+ } - @for (row of collections.controls; track row; let i = $index) { -
- - Party Code - - - - TRTP - - @for (mode of transactionModes; track mode) { - {{ mode }} - } - - - - AC Type - - - - A/C No. - - - - Amount - - - - Cheque No - - - - Token No - - - - Description - - - +
+ {{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }} + {{ pendingTotal | number: '1.2-2' }}
+ + +

+ Take {{ pendingTotal | number: '1.2-2' }} from {{ agentInfo.name }}, then post. +

}
} @@ -232,8 +287,16 @@ -
diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss index 0b527e5a0..6255bc5fa 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -9,9 +9,11 @@ $primary: #0f766e; .dte-container { - max-width: 1080px; + // Wider than a normal form page: a collection row carries six fields side by side, and a clerk + // keying a round should see the whole of it. + max-width: 1280px; margin: 0 auto; - padding: 24px 16px; + padding: 20px 16px; } .dte-header { @@ -93,6 +95,7 @@ $primary: #0f766e; margin-bottom: 12px; } +// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare. .dte-field { min-width: 180px; @@ -101,9 +104,18 @@ $primary: #0f766e; } } +// Inside the collection grid the track decides the width, not the field. Left to its own 180px +// floor a field refuses to shrink into a narrower track and overruns the one beside it. +.dte-collection-row .dte-field { + width: 100%; + min-width: 0; +} + .dte-section { padding: 16px 0; border-top: 1px solid rgb(0 0 0 / 8%); + // A very narrow window scrolls the grid sideways rather than the whole page. + overflow-x: auto; &__header { display: flex; @@ -140,9 +152,21 @@ $primary: #0f766e; gap: 8px 16px; } +/* + * One collection per row. Fixed columns rather than an even split, so the row holds its shape as + * fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party + * picker gets the width its "code · name · daily amount" needs. + */ .dte-collection-row { display: grid; - grid-template-columns: repeat(7, 1fr) auto; + grid-template-columns: + minmax(0, 2.2fr) /* Party */ + minmax(0, 1fr) /* TRTP */ + minmax(0, 1fr) /* AC Type */ + minmax(0, 1.4fr) /* A/C No. */ + minmax(0, 1fr) /* Amount */ + minmax(0, 1.6fr) /* Description */ + auto; /* remove */ gap: 8px 12px; align-items: start; padding: 10px 0; @@ -151,12 +175,27 @@ $primary: #0f766e; &:first-of-type { border-top: none; } + + // Cheque No and Token No push Description and the remove button onto a second line, which is + // what a non-cash entry should look like rather than a row running off the screen. + &:has(.dte-field--instrument) { + grid-template-columns: + minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) + minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto; + } } .dte-collection-row .dte-field--wide { grid-column: span 2; } +@media (width <= 1200px) { + .dte-collection-row, + .dte-collection-row:has(.dte-field--instrument) { + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + } +} + .dte-remove-row { color: #b91c1c; margin-top: 8px; @@ -185,3 +224,172 @@ $primary: #0f766e; grid-template-columns: repeat(2, 1fr); } } + +/* Resolved name under an Agent / Party Code field — the check that the right record was found. */ +.dte-hint--found { + color: #15803d; + font-weight: 500; +} + +.dte-hint--missing { + color: #b91c1c; + font-weight: 500; +} + +/* Marks an agent in the list who is no longer collecting - only offered for settlement. */ +.dte-agent-off { + color: #b45309; + font-size: 12px; + font-style: italic; + margin-left: 6px; +} + +/* The figure the clerk actually verifies against the passbook before saving. */ +.dte-after { + font-weight: 600; + color: #0f766e; +} + +/* What the agent should be handing over for this round. */ +.dte-total { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 14px; + padding: 10px 4px 2px; + border-top: 1px solid rgb(0 0 0 / 10%); + font-size: 0.88rem; + color: rgb(0 0 0 / 60%); + + strong { + font-size: 1.1rem; + color: rgb(0 0 0 / 85%); + font-variant-numeric: tabular-nums; + } +} + +/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */ +.dte-soon { + font-size: 0.78rem; + font-style: italic; + color: rgb(0 0 0 / 45%); +} + + +/* + * Marks a party this agent has already collected from today, on the row where it is picked. + * + * Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once - + * so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a + * whole round be credited twice. + */ +.dte-hint--again { + color: #b45309; + font-weight: 500; +} + +/* + * Where a party stands against its instalments. Three states rather than one colour, because they + * call for three different things from the clerk: ahead means take nothing, behind means take more + * than the daily amount, level means take the daily amount as usual. + * + * Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the + * agent's job to recover, not an error. Red here would cry wolf on most rows. + */ +.dte-hint--ahead, +.dte-sched--ahead { + color: #15803d; + font-weight: 500; +} + +.dte-hint--behind, +.dte-sched--behind { + color: #b45309; + font-weight: 500; +} + +.dte-hint--level, +.dte-sched--level { + color: rgb(0 0 0 / 55%); +} + +/* In the party panel the schedule line carries more weight than the fields around it. */ +.dte-sched { + font-weight: 500; +} + +/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */ +.dte-covered { + color: #15803d; + font-size: 0.8rem; +} + +/* + * An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary + * and the agent's job to recover, while this is a figure that probably was not handed over at all, + * and once saved it cannot be taken back. It still does not block - the clerk decides. + */ +.dte-hint--overpay { + color: #b91c1c; + font-weight: 500; +} + + +/* + The agent's written-down round, as the counter reads it. + + Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here, + only a list to check a bag of cash against, and controls would invite editing something the clerk + has no business editing. +*/ +.dte-pending-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 10px 2px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + + &:last-of-type { + border-bottom: none; + } + + &__code { + font-weight: 600; + min-width: 84px; + } + + &__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.75; + } + + &__mode { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.6; + } + + /* Amounts are read down the column and compared, so the digits have to line up. */ + &__amount { + font-variant-numeric: tabular-nums; + font-weight: 600; + min-width: 96px; + text-align: right; + } +} + +/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */ +.dte-expected { + margin: 14px 2px 0; + font-size: 14px; + line-height: 1.5; + + strong { + font-variant-numeric: tabular-nums; + } +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts index 0eb3edb83..92d8bcaaa 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -7,13 +7,20 @@ */ /** Angular Imports */ -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; + +/** Custom Components */ +import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; +import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; @@ -40,10 +47,12 @@ import { Dates } from 'app/core/utils/dates'; styleUrls: ['./daily-transaction-entry.component.scss'], imports: [...STANDALONE_SHARED_IMPORTS] }) -export class DailyTransactionEntryComponent { +export class DailyTransactionEntryComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + private dialog = inject(MatDialog); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); @@ -53,13 +62,18 @@ export class DailyTransactionEntryComponent { 'Withdraw Deposite': 'WITHDRAW_DEPOSITE' }; + /** + * The legacy screen's six types. Only the three the backend actually distinguishes can be picked - + * the rest are shown but not selectable, so the screen stays honest about what the system does + * today instead of accepting an entry it will reject on save. + */ transactionTypes = [ - 'Party Tran', - 'Agent Tran', - 'Party Payment', - 'OD Entry', - 'OD Payment', - 'Withdraw Deposite' + { label: 'Party Tran', supported: true }, + { label: 'Agent Tran', supported: true }, + { label: 'Withdraw Deposite', supported: true }, + { label: 'Party Payment', supported: false }, + { label: 'OD Entry', supported: false }, + { label: 'OD Payment', supported: false } ]; transactionModes = [ @@ -129,6 +143,263 @@ export class DailyTransactionEntryComponent { currentBalance: 0 }; + agentLookupFailed = false; + + /** Every agent still on the books - picked from a list rather than keyed by id. */ + agents: any[] = []; + + /** + * The picked agent's parties, fetched once when the agent is chosen. Every party code typed into + * the grid is resolved from here, so a clerk keying a round of thirty collections waits on the + * network once rather than thirty times - and a code belonging to another agent's party simply + * will not resolve, which is the check that matters. + */ + agentParties: any[] = []; + + /** + * The party behind the row being worked on, shown in the panel beside the agent's - who this + * collection is for, what they owe daily, and what their balance becomes once it is saved. + */ + partyInfo: any = null; + + /** Which grid row the party panel is showing. */ + activeRow = -1; + + /** + * What has already been keyed for this agent on the chosen date. + * + * The real risk on this screen is not a wrong figure, it is the same collection book being keyed + * twice - an interruption mid-round, and every customer in it gets credited again while the agent + * appears to be holding cash he already handed over. Showing the round back removes the guesswork. + */ + todaysEntries: any[] = []; + + get todaysTotal(): number { + return this.todaysEntries.reduce((sum, entry) => sum + Number(entry.amount || 0), 0); + } + + /** + * What this party has already been collected for on this date, if anything. + * + * Shown on the row itself rather than in a summary above it. A customer paying twice in a day is + * legitimate - arrears, or two days at once - so this never blocks the entry. It only has to be + * where the clerk is already looking at the moment they pick the party, which a panel at the top + * of the screen is not. + */ + alreadyCollected(index: number): number { + const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); + if (!partyCode) { + return 0; + } + return this.todaysEntries + .filter((entry) => String(entry.partyCode) === partyCode) + .reduce((sum, entry) => sum + Number(entry.amount || 0), 0); + } + + /** + * Where a party stands against the instalments it owes, in the words the branch uses. + * + * The running balance says how much has been paid, never whether it was paid on time - a customer + * four days ahead and one eleven days behind can hold the same figure. This is what the clerk needs + * at the moment they pick the party, so it is worked out per row rather than shown in a summary. + * + * A shortfall is split in two because the bank treats the halves differently: what is still inside + * the catch-up window can be collected today, and what has fallen outside it is gone. Putting them + * together would have the clerk asking for money the bank has already written off. + */ + schedule(party: any): { state: 'ahead' | 'behind' | 'level'; text: string } | null { + const daily = Number(party?.dailyDepositAmount || 0); + if (!party || daily <= 0) { + return null; + } + + const advance = Number(party.advanceAmount || 0); + if (advance > 0) { + return { state: 'ahead', text: `${this.money(advance)} already paid ahead` }; + } + + // What to ask for today is the recoverable shortfall exactly as it stands - today's own + // instalment is already inside it, because the schedule counts the first instalment on the + // opening day itself. Adding the daily on top would charge today twice, which on an account + // opened this morning reads as two days owed on its first day. + // + // Instalments past the catch-up window are deliberately left out of both the figure and the + // words - they cannot be collected, so naming them would only have the clerk asking for money + // the bank has already written off, and reading a shortfall the amount does not cover. + const recoverable = Number(party.recoverableAmount || 0); + if (recoverable > 0) { + return { state: 'behind', text: `Take ${this.money(recoverable)} to complete today` }; + } + + return { state: 'level', text: 'Up to date' }; + } + + private money(value: number): string { + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + /** The party on a given collection row, so the row can show where that party stands. */ + partyOfRow(index: number): any { + const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); + return partyCode ? this.agentParties.find((p) => String(p.partyCode) === partyCode) : null; + } + + /** + * Says so plainly when the agent just picked has nobody to collect from. + * + * An empty party list looks identical to a list that has not loaded, and a clerk who reads it as + * the second will sit waiting for names that are never coming. Raised as a dialog rather than a + * line under the field because it means this agent cannot be used for a round at all - there is + * nothing to key, and the clerk should go back and pick another. + */ + private warnIfAgentHasNoParties(): void { + if (this.agentParties.length > 0) { + return; + } + const agentId = this.entryForm.get('agentId')?.value; + const agent = this.agents.find((a: any) => String(a.id) === String(agentId)); + this.dialog.open(NoticeDialogComponent, { + data: { + heading: 'No customers for this agent', + // Named for what is actually missing. The agent's own settlement account is a different + // thing and is usually there - saying "no accounts" sends whoever reads it to check the + // wrong record. + message: + `No customer accounts are assigned to ${agent?.agentCode || 'this agent'}, so there is nothing ` + + 'to collect. Assign customers to this agent first, or choose a different agent.' + }, + width: '420px', + autoFocus: false + }); + } + + /** + * Adds a row for days other than today - paid ahead, or owed from days gone by. + * + * Kept off the ordinary row and put behind its own button, because most collections are simply + * today's instalment: a box asking about days would sit empty on nearly every row, and a stray + * value left in one would charge a customer for days they did not pay for. + * + * The row it adds is filled in and left in the list rather than posted, so it is saved with the + * rest of the round and the clerk sees it beside the others before committing. + */ + addDayBasedRow(mode: 'advance' | 'previous'): void { + const dialogRef = this.dialog.open(DayCollectionDialogComponent, { + data: { mode, parties: this.agentParties }, + width: '460px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((picked: any) => { + if (!picked) { + return; + } + this.addCollectionRow(); + const row = this.collections.at(this.collections.length - 1); + row.get('partyCode')?.setValue(picked.partyCode); + this.loadPartyForRow(this.collections.length - 1); + row.get('amount')?.setValue(picked.amount); + row.get('advanceDays')?.setValue(picked.advanceDays ?? ''); + row.get('arrearsDays')?.setValue(picked.arrearsDays ?? ''); + row.get('description')?.setValue( + `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` + ); + this.recalculateRowBalance(this.collections.length - 1); + }); + } + + /** + * Questions an amount on an ordinary row that is not the day's instalment. + * + * Paying for other days now has its own buttons, and each works out the amount from the scheme + * itself. What is left in the plain Amount box is one thing: today. Anything else is either a + * mistyped figure - a zero too many turns a hundred rupees into a thousand - or days being keyed by + * hand, which is the very thing the buttons exist to stop. + * + * It warns and does not block. A customer can hand over an odd amount, and the clerk is the one who + * saw them do it; what they need is to be told before saving, because a collection cannot be taken + * back afterwards - it can only be carried into the days that follow. + */ + overpayWarning(index: number): string | null { + const party = this.partyOfRow(index); + const daily = Number(party?.dailyDepositAmount || 0); + const amount = Number(this.collections.at(index)?.get('amount')?.value || 0); + if (!party || daily <= 0 || amount <= 0) { + return null; + } + + // Never raised on a row the Advance or Previous Days button filled in: those figures are the + // scheme's own and the server checks them, so they cannot be the slip this is looking for. + const row = this.collections.at(index); + if (row?.get('advanceDays')?.value || row?.get('arrearsDays')?.value) { + return null; + } + + if (amount === daily) { + return null; + } + if (amount > daily) { + return ( + `${this.money(amount)} is more than the daily ${this.money(daily)}. ` + + 'Use Advance to pay ahead, or Previous Days to clear arrears.' + ); + } + return `${this.money(amount)} is less than the daily ${this.money(daily)}.`; + } + + /** + * Whether this party is already paid ahead far enough that today is covered. + * + * The agent still calls - the round does not skip a door - but there is nothing to collect, so the + * row is closed to entry rather than hidden. A party that vanished from the list would have the + * clerk wondering what had happened to it. + */ + isCoveredByAdvance(party: any): boolean { + return Number(party?.advanceDays || 0) > 0; + } + + /** + * The agent's own written-down collections for the chosen date, and whether they are being read. + * + * Held rather than typed. A Party Tran is no longer something the clerk composes - the agent + * composed it on their round, and this screen shows it back so the cash can be counted against it. + */ + pendingEntries: any[] = []; + pendingLoading = false; + + /** What the agent should be handing over: their own entries, added up. */ + get pendingTotal(): number { + return this.pendingEntries.reduce((sum: number, entry: any) => sum + Number(entry.amount || 0), 0); + } + + /** + * Reads what this agent has outstanding for the date on the form. + * + * Asked again whenever the agent or the date changes, because both pick out a different day's + * round - and a list left over from the previous choice is a list the clerk might settle. + */ + loadPendingEntries(): void { + const agentId = this.entryForm.get('agentId')?.value; + const date = this.entryForm.get('transactionDate')?.value; + if (!agentId || !date) { + this.pendingEntries = []; + return; + } + + this.pendingLoading = true; + const on = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentCollectionDay(agentId, on).subscribe({ + next: (summary: any) => { + this.pendingEntries = (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING'); + this.pendingLoading = false; + }, + error: () => { + this.pendingEntries = []; + this.pendingLoading = false; + } + }); + } + get isPartyTran(): boolean { return this.entryForm.get('transactionType').value === 'Party Tran'; } @@ -146,7 +417,7 @@ export class DailyTransactionEntryComponent { } private buildCollectionRow(): UntypedFormGroup { - return this.formBuilder.group({ + const row = this.formBuilder.group({ partyCode: [ '', Validators.required @@ -164,6 +435,14 @@ export class DailyTransactionEntryComponent { 0, Validators.required ], + /* + * Days taken in advance, when the customer is paying ahead rather than for today. Empty on an + * ordinary row. The amount follows from it - the scheme's daily instalment times the days - so + * the clerk picks days, not rupees, and cannot quote a figure the scheme does not charge. + */ + advanceDays: [''], + /* Days of arrears being cleared. Same idea as advanceDays, the other way round. */ + arrearsDays: [''], chequeNo: [''], tokenNo: [''], description: [''], @@ -180,6 +459,17 @@ export class DailyTransactionEntryComponent { } ] }); + + // Cash has no instrument behind it, so switching back to cash drops anything typed against a + // cheque or clearing entry rather than saving a stale number with it. + row.get('transactionMode')?.valueChanges.subscribe((mode) => { + if (mode === 'Cash') { + row.get('chequeNo')?.setValue('', { emitEvent: false }); + row.get('tokenNo')?.setValue('', { emitEvent: false }); + } + }); + + return row; } addCollectionRow(): void { @@ -190,28 +480,163 @@ export class DailyTransactionEntryComponent { this.collections.removeAt(index); } - loadAgent(): void { - const agentId = this.entryForm.get('agentId')?.value; - if (!agentId) { - return; - } - this.organizationService.getAgent(agentId).subscribe((agent: any) => { - const guarantors = agent.guarantors || []; - this.agentInfo = { - name: agent.agentNameId || '', - address: agent.agentAddress1 || '', - city: agent.agentCity || '', - accountNo: agent.agentSavingAccountNo || '', - guarantor1: guarantors[0]?.guarantorName || '', - guarantor2: guarantors[1]?.guarantorName || '', - currentBalance: agent.agentCurrentBalance || 0 - }; + ngOnInit(): void { + // Closed agents are off the books entirely; everyone else is kept, and which of them can be + // picked depends on the transaction type - see selectableAgents. + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)), + error: () => (this.agents = []) }); } + /** + * The agents that can be picked for the transaction type currently chosen. + * + * A deactivated agent no longer collects, so they are not offered for a collection or a transfer. + * Settlement is the exception: an agent switched off mid-round is still holding the cash they + * already took, and that cash has to be able to reach a branch. Leaving them out of the settlement + * list would strand it with no way to hand it in. + */ + get selectableAgents(): any[] { + if (this.isAgentTran) { + return this.agents; + } + return this.agents.filter((agent: any) => agent.active); + } + + /** Flags a picked agent that is no longer collecting, so the entry is not made by mistake. */ + get pickedAgentIsInactive(): boolean { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return false; + } + const picked = this.agents.find((agent: any) => agent.id === agentId); + return !!picked && !picked.active; + } + + /** Whether this row needs a cheque or token number - a cash collection has neither. */ + needsInstrument(index: number): boolean { + return this.collections.at(index)?.get('transactionMode')?.value !== 'Cash'; + } + + loadAgent(): void { + const agentId = this.entryForm.get('agentId')?.value; + this.agentLookupFailed = false; + this.partyInfo = null; + this.activeRow = -1; + if (!agentId) { + this.agentParties = []; + this.pendingEntries = []; + return; + } + + this.organizationService.getAgent(agentId).subscribe({ + next: (agent: any) => { + const guarantors = agent.guarantors || []; + this.agentInfo = { + name: agent.agentNameId || '', + address: agent.agentAddress1 || '', + city: agent.agentCity || '', + accountNo: agent.agentSavingAccountNo || '', + guarantor1: guarantors[0]?.guarantorName || '', + guarantor2: guarantors[1]?.guarantorName || '', + currentBalance: agent.agentCurrentBalance || 0 + }; + }, + error: () => { + this.agentLookupFailed = true; + this.agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 }; + } + }); + + this.organizationService.getPartiesOfAgent(agentId).subscribe({ + next: (parties: any[]) => { + this.agentParties = parties || []; + this.warnIfAgentHasNoParties(); + }, + error: () => (this.agentParties = []) + }); + + this.loadTodaysEntries(); + this.loadPendingEntries(); + } + + /** This agent's collections already recorded for the date on the screen. */ + loadTodaysEntries(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + this.todaysEntries = []; + return; + } + const onDate = this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd'); + + this.organizationService.getPartyTransactionsOfAgent(agentId).subscribe({ + next: (transactions: any[]) => { + this.todaysEntries = (transactions || []).filter( + (transaction: any) => + transaction.transactionType === 'PARTY_TRAN' + && this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate + ); + }, + error: () => (this.todaysEntries = []) + }); + } + + /** + * Resolves the party code typed into a row. A code this agent does not collect for is left + * unresolved rather than fetched anyway - an agent posting against someone else's party is the + * mistake this screen most needs to catch. + */ + loadPartyForRow(index: number): void { + const row = this.collections.at(index); + if (!row) { + return; + } + const partyCode = String(row.get('partyCode')?.value || '').trim(); + this.activeRow = index; + + const party = this.agentParties.find((candidate) => String(candidate.partyCode) === partyCode); + this.partyInfo = party || null; + + row.get('partyName')?.setValue(party?.partyName || '', { emitEvent: false }); + // The account credited is the party's own linked savings account, never keyed by hand. + row.get('accountNo')?.setValue(party?.creditToAccount || party?.partyCode || '', { emitEvent: false }); + row.get('accountType')?.setValue(party?.creditToAccountType || 'Savings', { emitEvent: false }); + + this.recalculateRowBalance(index); + } + + /** The party's balance once this row is saved - what the legacy screen called Bal. After This Tr. */ + recalculateRowBalance(index: number): void { + const row = this.collections.at(index); + if (!row) { + return; + } + const party = this.agentParties.find( + (candidate) => String(candidate.partyCode) === String(row.get('partyCode')?.value || '').trim() + ); + const current = Number(party?.currentBalance || 0); + const amount = Number(row.get('amount')?.value || 0); + row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false }); + } + + /** The balance the party panel shows - live against whatever the active row currently holds. */ + get partyBalanceAfter(): number { + if (!this.partyInfo || this.activeRow < 0) { + return 0; + } + const row = this.collections.at(this.activeRow); + return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0); + } + + /** Total of the round being keyed in - what the agent should be handing over. */ + get collectionTotal(): number { + return this.collections.controls.reduce((sum, row) => sum + Number(row.get('amount')?.value || 0), 0); + } + + /** Re-reads what has already been keyed for this agent on the chosen date. */ retrieve(): void { - // No "my assigned parties" read API exists yet — this will pre-fill - // the collections grid with today's assigned parties once available. + this.loadTodaysEntries(); } viewLedger(): void { @@ -231,11 +656,65 @@ export class DailyTransactionEntryComponent { } exit(): void { - this.router.navigate(['/accounts/savings']); + this.router.navigate(['/accounts/dds']); } + /** + * Save, asking first when what is about to happen is a settlement. + * + * A settlement credits every customer on the list and records the cash as the agent's, in one + * act that cannot be taken back - so the screen says the figure out loud and waits. The question + * is the one the clerk is actually answering at the counter: is this much really in front of me. + * + * The double-entry guard this used to carry has moved to where the entries are now made. Two + * lines for one account on one day are refused on the agent's own screen, and refused again by + * the server when the day is settled; by the time a clerk sees the list there is nothing left to + * warn about. + */ save(): void { + if (!this.isPartyTran) { + this.postTransaction(); + return; + } + + if (this.pendingEntries.length === 0) { + return; + } + + const money = Number(this.pendingTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const count = this.pendingEntries.length; + + this.dialog + .open(ConfirmationDialogComponent, { + data: { + type: 'warn', + heading: 'Settle these collections', + dialogContext: + `Take ${money} from ${this.agentInfo.name || 'the agent'} and post ` + + `${count} ${count === 1 ? 'collection' : 'collections'}? ` + + 'Every customer on the list is credited and the cash is recorded against the agent. ' + + 'This cannot be undone.' + } + }) + .afterClosed() + .subscribe((response: any) => { + if (response?.confirm) { + this.postTransaction(); + } + }); + } + + private postTransaction(): void { const raw = this.entryForm.getRawValue(); + + // A Party Tran is no longer a transaction this screen composes. The agent wrote the round down + // on their own; what happens here is that it is settled, all of it at once, through a call of + // its own. Everything else on this screen still posts a transaction the way it always did. + if (this.isPartyTran) { + this.settleCollections(); + return; + } + const dateFormat = this.settingsService.dateFormat; const locale = this.settingsService.language.code; const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType]; @@ -248,18 +727,7 @@ export class DailyTransactionEntryComponent { locale }; - if (this.isPartyTran) { - payload.collections = (raw.collections || []).map((row: any) => ({ - partyCode: row.partyCode, - transactionMode: row.transactionMode, - accountType: row.accountType || undefined, - accountNo: row.accountNo, - amount: row.amount, - chequeNo: row.chequeNo || undefined, - tokenNo: row.tokenNo || undefined, - description: row.description || undefined - })); - } else if (this.isWithdrawDeposite) { + if (this.isWithdrawDeposite) { payload.creditorPartyCode = raw.creditorPartyCode; payload.debitPartyCode = raw.debitPartyCode; payload.withdrawAmount = raw.withdrawAmount; @@ -272,8 +740,19 @@ export class DailyTransactionEntryComponent { payload.description = raw.settlementDescription || undefined; } + // What was just posted, captured before the form is cleared, so the confirmation can name it. + const posted = this.postedSummary(raw); + this.organizationService.createPartyTransaction(payload).subscribe(() => { + // On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk + // who looks away reasonably assumes it did not go through and posts the whole round again. + this.alertService.alert({ type: 'Success', message: posted }); + this.collections.clear(); + this.partyInfo = null; + this.activeRow = -1; + // The round just saved becomes part of what the screen shows back. + this.loadTodaysEntries(); this.entryForm.patchValue({ creditorPartyCode: '', debitPartyCode: '', @@ -287,4 +766,66 @@ export class DailyTransactionEntryComponent { this.loadAgent(); }); } + + /** + * Hands the agent's day over: every entry they wrote down for this date becomes a real credit. + * + * No amount is sent and none is asked for on screen. What settles is what the entries say - a + * figure typed at the counter would be a second opinion about a total the entries already give, + * and the two could differ. The clerk's part is to count the cash against that total and agree. + * + * All or none. A part-settled round would leave the branch holding cash against a list that no + * longer matched it, which is a worse position than simply running it again. + */ + private settleCollections(): void { + const raw = this.entryForm.getRawValue(); + if (this.pendingEntries.length === 0) { + return; + } + + const count = this.pendingEntries.length; + const total = this.pendingTotal; + const agent = this.agentInfo.name || 'agent'; + + this.organizationService + .settleAgentCollections({ + agentId: Number(raw.agentId), + entryDate: this.dateUtils.formatDate(raw.transactionDate, 'yyyy-MM-dd'), + dateFormat: 'yyyy-MM-dd', + locale: this.settingsService.language.code + }) + .subscribe(() => { + const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + this.alertService.alert({ + type: 'Success', + message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money + }); + + // The list empties because those entries are settled now, and the agent's cash goes up by + // what was just taken - both are read again rather than worked out on screen. + this.loadPendingEntries(); + this.loadTodaysEntries(); + this.loadAgent(); + }); + } + + /** + * Says what was actually posted, in the terms the clerk was working in - a count and a total for a + * round of collections, the amount and who it came from for anything else. "Saved successfully" + * would confirm that something happened without confirming that the right thing happened. + */ + private postedSummary(raw: any): string { + const money = (value: any) => Number(value || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const agent = this.agentInfo.name || 'agent'; + + if (this.isPartyTran) { + const rows = raw.collections || []; + const total = rows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0); + return `${rows.length} ${rows.length === 1 ? 'collection' : 'collections'} posted for ${agent} — ${money(total)}`; + } + if (this.isAgentTran) { + return `${agent} settled ${money(raw.settlementAmount)} to the branch`; + } + return `${money(raw.withdrawAmount)} transferred from ${raw.debitPartyCode} to ${raw.creditorPartyCode}`; + } } diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html new file mode 100644 index 000000000..553aeae7a --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html @@ -0,0 +1,58 @@ + + +

{{ title }}

+ + + + Party + + @for (p of eligibleParties; track p.id) { + + {{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }} + + } + + @if (eligibleParties.length === 0) { + + No party has recoverable days + } + + + + Days + + @for (d of dayOptions; track d) { + {{ d }} {{ d === 1 ? 'day' : 'days' }} + } + + @if (isAdvance) { + Up to 5 days + } @else if (partyCode) { + Only days still within the catch-up window + } + + + + @if (amount > 0) { +
+ {{ amount | number: '1.2-2' }} + + {{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily + +
+ } +
+ + + + + diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss new file mode 100644 index 000000000..3f31a5163 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss @@ -0,0 +1,42 @@ +/** + * 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/. + */ + +.dcd-field { + width: 100%; +} + +.dcd-none { + color: #b45309; +} + +/* + * The figure the clerk quotes at the counter. Larger than the fields above it because it is the one + * thing the customer is told, and it is shown with its basis so the clerk can say where it came from + * rather than just naming a number. + */ +.dcd-amount { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 6px; + padding: 12px 14px; + border-radius: 6px; + background: rgb(15 118 110 / 8%); + + &__figure { + font-size: 1.3rem; + font-weight: 600; + color: #0f766e; + font-variant-numeric: tabular-nums; + } + + &__basis { + font-size: 0.8rem; + color: rgb(0 0 0 / 60%); + } +} diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts new file mode 100644 index 000000000..cc3951f62 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts @@ -0,0 +1,117 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +/** + * Collecting for days other than today - either paid ahead, or owed from days gone by. + * + * Both are the same act from the counter's side: the customer is paying for a number of days, and + * what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the + * amount follows. Typing an amount instead would let the daily figure be worked around a little at a + * time, and would quote the customer something the scheme does not charge. + * + * The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance. + * Paying arrears is capped by what is still inside the catch-up window - older instalments are gone, + * and offering them would have the clerk asking for money the bank has already written off. + */ +@Component({ + selector: 'mifosx-day-collection-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule + ], + templateUrl: './day-collection-dialog.component.html', + styleUrls: ['./day-collection-dialog.component.scss'] +}) +export class DayCollectionDialogComponent { + private dialogRef = inject(MatDialogRef); + /** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */ + data = inject(MAT_DIALOG_DATA); + + partyCode = ''; + days: number | '' = ''; + + get isAdvance(): boolean { + return this.data?.mode === 'advance'; + } + + get title(): string { + return this.isAdvance ? 'Collect in advance' : 'Collect previous days'; + } + + get party(): any { + return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null; + } + + /** + * The parties this can be used on. + * + * Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still + * inside the window - on an account that is up to date there is nothing to collect for past days, + * and listing it would invite an entry that is really an advance in disguise. + */ + get eligibleParties(): any[] { + const parties = this.data?.parties || []; + return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0); + } + + /** + * How many days may be picked. Five ahead, matching the bank's allowance and the server's own + * check; for arrears, only what has not yet fallen out of the catch-up window. + */ + get dayOptions(): number[] { + const daily = Number(this.party?.dailyDepositAmount || 0); + if (!this.party || daily <= 0) { + return []; + } + const limit = this.isAdvance + ? 5 + : Math.floor(Number(this.party.recoverableAmount || 0) / daily); + return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1); + } + + get amount(): number { + return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0); + } + + /** Clearing the days when the party changes - a count valid for one account is not for another. */ + partyChanged(): void { + this.days = ''; + } + + get canAdd(): boolean { + return !!this.partyCode && Number(this.days) > 0 && this.amount > 0; + } + + add(): void { + this.dialogRef.close({ + partyCode: this.partyCode, + days: Number(this.days), + amount: this.amount, + // Sent as days, not just an amount: the server recomputes what they come to and checks it + // against this account, so the figure quoted here is the one that gets posted. + advanceDays: this.isAdvance ? Number(this.days) : undefined, + arrearsDays: this.isAdvance ? undefined : Number(this.days) + }); + } +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html new file mode 100644 index 000000000..ee905a5bc --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html @@ -0,0 +1,21 @@ + + +
+ +

{{ data.heading }}

+
+ + +

{{ data.message }}

+
+ + + + + diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss new file mode 100644 index 000000000..1962f43c2 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss @@ -0,0 +1,44 @@ +/** + * 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/. + */ + +.nd-head { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 24px 4px; + + /* + * Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here + * and nothing is at risk - the clerk is being told a fact about the agent they picked, and a + * warning colour would send them looking for a fault that is not there. + */ + &__ic { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: rgb(15 118 110 / 10%); + color: #0f766e; + flex: none; + } + + &__title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.nd-message { + margin: 0; + max-width: 44ch; + font-size: 0.9rem; + line-height: 1.55; + color: rgb(0 0 0 / 70%); +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts new file mode 100644 index 000000000..906d5b209 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts @@ -0,0 +1,39 @@ +/** + * 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, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** + * Something the clerk needs told, with nothing to decide. + * + * The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a + * choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and + * "Cancel" reads as though the thing being described could be undone. The error dialog has the right + * single button but dresses the message in red, which turns "this agent has no parties" into a fault. + * + * So: one button, and the message stands on its own. + */ +@Component({ + selector: 'mifosx-notice-dialog', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + FaIconComponent + ], + templateUrl: './notice-dialog.component.html', + styleUrls: ['./notice-dialog.component.scss'] +}) +export class NoticeDialogComponent { + /** { heading, message } */ + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html index 4b7601dc4..a61caf5f9 100644 --- a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html @@ -16,46 +16,59 @@
-
- - From Date - - - - + + @if (!hasNoFilters) { +
+ @if (usesDate) { + + {{ dateLabel }} — from + + + + - - To Date - - - - + + {{ dateLabel }} — to + + + + + } - - Agent From - - + @if (usesAgent) { + + Agent From + + - - Agent To - - + + Agent To + + + } - - Party From - - + @if (usesParty) { + + Party From + + - - Party To - - -
+ + Party To + + + } +
+ }
+
@@ -66,25 +79,66 @@ } @else if (rows.length === 0) {

No rows matched these filters.

} @else { -
- - - - @for (column of columns; track column) { - - } - - - - @for (row of rows; track row) { + +
+
+

{{ reportName }}

+ @if (usesDate) { +

+ {{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and + {{ ranToDate | dateFormat }} +

+ } @else { +

as on {{ ranOnDate | dateFormat }}

+ } + +
+ +
+
{{ column }}
+ + @for (column of columns; track column) { - + } + + + @for (row of rows; track $index) { + + + @for (column of columns; track column) { + + } + + } + + @if (hasTotalledColumn) { + + + + @for (column of columns; track column; let first = $first) { + + } + + } - -
#{{ row[column] }}{{ column }}
{{ $index + 1 }}{{ cell(row, column) }}
+ @if (first) { + Total : + } @else if (isTotalledColumn(column)) { + {{ columnTotal(column) }} + } +
+ +
+ +

+ {{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }} +

} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss index 454eb7c33..5fd19d364 100644 --- a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss @@ -94,6 +94,93 @@ $primary: #0f766e; padding: 8px 10px; border-bottom: 1px solid rgb(0 0 0 / 6%); } + + /* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */ + .rv-num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + /* The row number down the left, as the printed statement carried it. */ + .rv-sr { + width: 1%; + color: rgb(0 0 0 / 45%); + font-style: italic; + font-variant-numeric: tabular-nums; + } + + tfoot td { + border-bottom: none; + border-top: 2px solid rgb(0 0 0 / 20%); + font-weight: 600; + padding-top: 10px; + } +} + +/* ---- the statement block: heading, table, footer count ---- */ + +.rv-action-bar i { + margin-right: 6px; +} + +.rv-statement__head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 12px; + margin-bottom: 14px; +} + +.rv-statement__title { + margin: 0; + font-size: 17px; + font-weight: 600; + flex: 0 0 auto; +} + +.rv-statement__period { + margin: 0; + font-size: 13px; + color: rgb(0 0 0 / 55%); +} + +.rv-print { + margin-left: auto; + + i { + margin-right: 6px; + } +} + +.rv-statement__count { + margin: 10px 0 0; + font-size: 12px; + color: rgb(0 0 0 / 45%); + text-align: right; +} + +/* Printing a statement should give the statement, not the screen around it. */ +@media print { + .rv-header, + .rv-grid, + .rv-action-bar, + .rv-print { + display: none !important; + } + + .rv-card { + box-shadow: none; + padding: 0; + } + + .rv-table-scroll { + overflow-x: visible; + } + + .rv-table { + white-space: normal; + font-size: 11px; + } } @media (width <= 900px) { diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts index 2ab8c9985..30732c80c 100644 --- a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -37,7 +37,7 @@ import { Dates } from 'app/core/utils/dates'; styleUrls: ['./report-viewer.component.scss'], imports: [...STANDALONE_SHARED_IMPORTS] }) -export class ReportViewerComponent { +export class ReportViewerComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private formBuilder = inject(UntypedFormBuilder); @@ -66,6 +66,41 @@ export class ReportViewerComponent { 'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT' }; + /** + * Which filters each report actually uses, and what its date range means. + * + * Every report used to be offered the same six filters whether it read them or not — so a date + * range could be typed on the Agent Check List, which has no date in its query at all, and the + * same rows would come back however it was set. A filter that changes nothing is worse than no + * filter: it reads as a report that ignores what it was asked. + * + * Kept in step with the backend by hand; the source of truth is the switch in + * `DdsReportReadPlatformServiceImpl.runReport`. + */ + private static readonly REPORT_FILTERS: Record = { + AGENT_CHECK_LIST: {}, + PARTY_CHECK_LIST: { agent: true, party: true }, + PARTY_BALANCE: { agent: true, party: true }, + AGENT_WISE_BALANCE: { agent: true }, + BALANCE_SUMMARY: { agent: true, party: true }, + MASTER_DATA_EXPORT: { agent: true, party: true }, + + AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true }, + COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true }, + DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true }, + AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true }, + SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true }, + + COMMISSION_REGISTER: { date: 'Posted between', agent: true }, + AGENT_COMMISSION_STATEMENT: { date: 'Posted between' }, + PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true }, + INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true }, + + TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true }, + CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true }, + MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true } + }; + reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report'; hasRun = false; @@ -74,21 +109,198 @@ export class ReportViewerComponent { columns: string[] = []; rows: Array> = []; + /** The range the rows on screen were actually fetched for — not whatever the filter says now. */ + ranFromDate: Date | null = null; + ranToDate: Date | null = null; + + /** When a report carries no date range, the moment it was run is what dates its figures. */ + ranOnDate: Date | null = null; + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + private static readonly DATE = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + + /** + * One cell, rendered the way a printed statement shows it. + * + * Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them + * as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures + * against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric + * (account numbers, codes) are left alone, since grouping them would corrupt them. + */ + cell(row: Record, column: string): string { + const value = row[column]; + + if (value === null || value === undefined || value === '') { + return '—'; + } + if (Array.isArray(value) && value.length >= 3) { + return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2])); + } + if (this.isAmountColumn(column) && typeof value === 'number') { + return ReportViewerComponent.MONEY.format(value); + } + return String(value); + } + + /** + * Whether a column holds money, and so should be right-aligned with two decimals. + * + * Decided from the column's name rather than its values: a count column and an account-number + * column are both numbers, and neither reads as an amount. + */ + isAmountColumn(column: string): boolean { + const name = column.toLowerCase(); + + // Ruled out first, because several of these also contain a money word: a rate is a percentage, + // "Total Accounts" is a count that happens to say total, and an account number is an identifier + // that would be corrupted by grouping. + // + // "agents" is matched exactly rather than as a substring: the two count columns are named + // "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents" + // are money, and a substring test swept those two up and printed them ungrouped. + // + // A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest", + // which is enough for the money test below to claim it and put a meaningless total under it. + if ( + name.includes('a/c no') || + name.includes('code') || + name.includes('rate') || + name.includes('%') || + name.includes('count') || + name.includes('entries') || + name.includes('accounts') || + name === 'total agents' || + name === 'active agents' || + name.includes('type') || + name.includes('pan') + ) { + return false; + } + + return ( + name.includes('amount') || + name.includes('balance') || + name.includes('collect') || + name.includes('commission') || + name.includes('deduction') || + name.includes('deposit') || + name.includes('advance') || + name.includes('interest') || + name.includes('tds') || + name.includes('total') || + name.includes('diff') || + name.includes('cash') || + name.includes('debit') || + name.includes('credit') || + name.includes('settled') || + name.includes('shortfall') || + name.includes('due') + ); + } + + /** + * Whether a total under this column would mean anything. + * + * Not every amount is worth adding up. A "Balance After" on a transaction register is the running + * balance *at that moment*, so its column holds several snapshots of the same account at different + * times — summing them produces a figure that corresponds to nothing, and a reader would sooner or + * later try to reconcile it against something. + * + * A plain balance on a listing report is different: there each row is one account's position at + * one moment, and the sum is the real "total held", which is exactly what a branch wants to see. + */ + isTotalledColumn(column: string): boolean { + if (!this.isAmountColumn(column)) { + return false; + } + return !column.toLowerCase().includes('balance after'); + } + + /** The figure printed under a column, as the legacy statement's "Total :" row does. */ + columnTotal(column: string): string { + const sum = this.rows.reduce((running, row) => { + const value = row[column]; + return running + (typeof value === 'number' ? value : 0); + }, 0); + return ReportViewerComponent.MONEY.format(sum); + } + + /** + * Whether to print a Total row at all. + * + * A single-row report is already its own total - Balance Summary is one line of figures for the + * whole scheme, and repeating it underneath itself only invites the reader to look for a difference + * between the two. + */ + get hasTotalledColumn(): boolean { + return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column)); + } + + print(): void { + window.print(); + } + filterForm: UntypedFormGroup = this.formBuilder.group({ - fromDate: [ - '', - Validators.required - ], - toDate: [ - '', - Validators.required - ], + fromDate: [''], + toDate: [''], agentFrom: [1], agentTo: [99999], partyFrom: [1], partyTo: [99999999] }); + private get filters(): { date?: string; agent?: boolean; party?: boolean } { + return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {}; + } + + /** True when this report's query actually reads a date range - only then is one asked for. */ + get usesDate(): boolean { + return !!this.filters.date; + } + + /** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */ + get dateLabel(): string { + return this.filters.date ?? ''; + } + + get usesAgent(): boolean { + return !!this.filters.agent; + } + + get usesParty(): boolean { + return !!this.filters.party; + } + + /** True when this report takes no filters at all, so there is nothing for anyone to fill in. */ + get hasNoFilters(): boolean { + return !this.usesDate && !this.usesAgent && !this.usesParty; + } + + ngOnInit(): void { + // A date is required exactly for the reports that read one, so a report without dates can be run + // straight away instead of demanding two values it will ignore. + if (this.usesDate) { + this.filterForm.get('fromDate')?.setValidators([Validators.required]); + this.filterForm.get('toDate')?.setValidators([Validators.required]); + this.filterForm.get('fromDate')?.updateValueAndValidity(); + this.filterForm.get('toDate')?.updateValueAndValidity(); + } + + // Nothing to narrow it by means nothing to decide, so the report simply opens showing its + // figures. Making someone press Run on an empty form asks a question with only one answer. + if (this.hasNoFilters) { + this.runReport(); + } + } + runReport(): void { const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName]; if (!reportKey) { @@ -96,20 +308,34 @@ export class ReportViewerComponent { } const raw = this.filterForm.getRawValue(); + // Only what this report reads is sent. The backend defaults the rest to the full range, so a + // report that ignores a filter is never handed a value that looks like it was applied. + const params: Record = {}; + if (this.usesDate) { + params['fromDate'] = this.dateUtils.getDate(raw.fromDate); + params['toDate'] = this.dateUtils.getDate(raw.toDate); + } + if (this.usesAgent) { + params['agentFrom'] = raw.agentFrom; + params['agentTo'] = raw.agentTo; + } + if (this.usesParty) { + params['partyFrom'] = raw.partyFrom; + params['partyTo'] = raw.partyTo; + } + this.organizationService - .runDdsReport(reportKey, { - fromDate: this.dateUtils.getDate(raw.fromDate), - toDate: this.dateUtils.getDate(raw.toDate), - agentFrom: raw.agentFrom, - agentTo: raw.agentTo, - partyFrom: raw.partyFrom, - partyTo: raw.partyTo - }) + .runDdsReport(reportKey, params) .subscribe((result: any) => { this.available = result.available; this.unavailableMessage = result.message || ''; this.columns = result.columns || []; this.rows = result.rows || []; + // Captured here so the printed heading names the range these rows came from, and does not + // silently change if the filter is edited without re-running. + this.ranFromDate = raw.fromDate; + this.ranToDate = raw.toDate; + this.ranOnDate = new Date(); this.hasRun = true; }); } diff --git a/src/app/deposits/party-master/CBS-Backend.code-workspace b/src/app/deposits/party-master/CBS-Backend.code-workspace new file mode 100644 index 000000000..5443c10bb --- /dev/null +++ b/src/app/deposits/party-master/CBS-Backend.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../../../../../../CBS-Backend" + }, + { + "path": "../../../../.." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/src/app/deposits/party-master/create-party/create-party.component.html b/src/app/deposits/party-master/create-party/create-party.component.html new file mode 100644 index 000000000..66ba08cdd --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.html @@ -0,0 +1,975 @@ + + +
+
+ +

{{ partyId ? 'Edit Party' : 'Party Master Entry' }}

+ @if (isClosed) { + Closed — view only + } +
+ +
+ + + +
+
+
+ +

Customer & Scheme

+ Who this account is for, which scheme it runs under, and which agent collects for it +
+
+
+ + + + + + @for (client of clientOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } + + @if (clientListIsPartial) { + + Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest + + } +
+ @if (loadedClientName) { + {{ loadedClientName }} + } @else if (clientLookupFailed) { + No client with this ID + } +
+
+ +
+ + + + + + @for (type of subTypeOptions; track type.id) { + +
+ {{ type.accountTypeCode }} + {{ type.accountTypeDescription }} + {{ type.accountTypeGroup }} +
+
+ } + @if (subTypeOptions.length === 0) { + No scheme matches that. + } +
+ @if (subTypes.length === 0) { + No active account type — create one first + } +
+
+ +
+ + + + + + @for (agent of agentOptions; track agent.id) { + +
+ {{ agent.agentCode }} + {{ agent.agentNameId }} + + + {{ agent.active ? agent.homeBranch : 'not collecting' }} + +
+
+ } + @if (agentOptions.length === 0) { + No agent matches that. + } +
+ @if (loadedAgentName && !agentLookupFailed) { + {{ loadedAgentName }} + } @else if (loadedAgentName && agentLookupFailed) { + {{ loadedAgentName }} is deactivated + } @else if (agentLookupFailed) { + No agent with this ID + } +
+
+ + +
+ + + + @for (office of offices; track office.id) { + {{ office.name }} + } + + @if (officesError) { + {{ officesError }} + } @else { + The branch this account belongs to + } + +
+ + + @if (savingsAccountNo) { +
+ + +
+ } @else if (partyId) { +
+ + +
+ } @else { +
+ + +
+ } + +
+ + + + +
+ +
+ + + + +
+ +
+ + + + Yes + No + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + Male + Female + Other + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + @for (op of operations; track op) { + {{ op }} + } + + +
+ +
+ + + + + + +
+ +
+ + + + @for (occ of occupations; track occ) { + {{ occ }} + } + + +
+ +
+ + + + @for (r of riskLevels; track r) { + {{ r }} + } + + +
+
+
+ +
+
+ +

Minor / Guardian

+ Only for a minor's account — the guardian who operates it until they come of age +
+
+
+ + + + @for (opt of adultMinorOptions; track opt) { + {{ opt }} + } + + +
+ + @if (isMinorAccount) { +
+ + + + + + Turns 18 on + +
+ +
+ + + + + + @for (client of guardianOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ @if (loadedGuardianName) { + {{ loadedGuardianName }} + } +
+
+ +
+ + + + +
+ +
+ + + + To the minor + +
+ } +
+ @if (!isMinorAccount) { +

An adult's account needs no guardian details.

+ } +
+ +
+
+ +

Scheme Terms

+ How much is collected daily, for how long, and what it matures to +
+
+
+ + + + + + + +
+ +
+ + + + +
+ +
+ + + + + Calculated + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + @if (partyForm.get('days')?.hasError('exceedsTerm')) { + Only {{ termDays }} days in this term + } @else if (termDays) { + Term is {{ termDays }} days + } + +
+ +
+ + + + Collection book no. + +
+ +
+ + + + Calculated + +
+ +
+ + + + % + +
+ +
+ + + + @for (type of withdrawTypes; track type) { + {{ type }} + } + + +
+
+
+ +
+
+ +

Balances & Status

+ Kept by collections and the nightly batch — shown here, never typed +
+
+
+ OD1 Flag +
+
+ OD2 Flag +
+ +
+ + + + Yes + No + + @if (lienHeld) { + Nothing can leave this account until it is released + } + +
+ + + @if (lienHeld) { +
+ + + + @for (reason of lienReasons; track reason) { + {{ reason }} + } + + What this money is being held for + +
+ + + @if (lienReasonIsOther) { +
+ + + + Write the reason in your own words + +
+ } + } + +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ Is SMS Active +
+
+ Is Email Active +
+ +
+ + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + @for (code of freezeCodes; track code) { + {{ code }} + } + + +
+ +
+ + + + @for (code of advCodes; track code) { + {{ code }} + } + + +
+
+
+ + @if (creditToRequired) { +
+
+ +

Maturity Payout Routing

+
+

Transfer To Saving — say where the maturity proceeds should land.

+
+
+ + + + @for (office of offices; track office.id) { + {{ office.name }} + } + + @if (officesError) { + {{ officesError }} + } + +
+ + +
+ + + + @for (account of creditToAccounts; track account.id) { + + {{ account.accountNo }} · {{ account.productName }} + + } + + @if (creditToAccounts.length === 0) { + + {{ loadedClientName || 'This customer' }} has no other savings account to credit + + } + +
+ + +
+ + + + +
+
+
+ } +
+
+ + +
+
+
+
+ + + + Yes + No + + +
+ + @if (nomineesRequired) { + + Percentage + Sequence + + + + + @if (splitByPercentage && nominees.length > 0) { + + } @else if (splitBySequence && nominees.length > 0) { + + } + } +
+ + @if (!nomineesRequired) { +

Set Nominee to Yes to nominate someone for this account.

+ } + + @for (nominee of nominees.controls; track nominee; let i = $index) { +
+
+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + + + +
+
+ + + + + From date of birth + +
+
+ + + + +
+
+ + + + Male + Female + Other + + +
+
+ + + + @if (isNomineeMinor(nominee)) { + Required for a minor + } + +
+ @if (splitByPercentage) { +
+ + + + +
+ } @else { +
+ + + + +
+ } + +
+ } +
+
+
+
+ + @if (isClosed) { +
+ + This account is closed. No further collections or edits are possible. +
+ } + + + @if (minorTurnedMajor) { +
+ + + This account was opened for a minor who came of age on + {{ majorOn | dateFormat }}. The guardian can no longer + operate it — take fresh KYC, a signature and a new mandate from the account holder. + +
+ } + +
+ + + + @if (partyId && !isClosed && canCloseEarly) { + + } + @if (partyId && !isClosed) { + + } + +
+ +
+
diff --git a/src/app/deposits/party-master/create-party/create-party.component.scss b/src/app/deposits/party-master/create-party/create-party.component.scss new file mode 100644 index 000000000..df7e0918a --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.scss @@ -0,0 +1,526 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.pm-container { + // Wider than the usual form page: the extra width is what keeps this many fields off a + // second screen. + max-width: 1280px; + margin: 0 auto; + padding: 12px 16px 20px; +} + +.pm-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; + + &__title { + margin: 0; + font-size: 18px; + font-weight: 500; + } +} + +.pm-back { + color: $primary; +} + +.pm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.pm-tabs { + ::ng-deep .mat-mdc-tab-body-wrapper { + min-height: 300px; + } +} + +.pm-tab-content { + padding: 20px 24px; +} + +.pm-section { + padding: 16px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + // The subtitle sits beside the title where there is room and drops below it where there is not. + flex-wrap: wrap; + gap: 2px 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + // Held at its natural width: letting it shrink breaks a two-word heading onto three lines. + flex: 0 0 auto; + white-space: nowrap; + } + } +} + +.pm-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2px 18px; + align-items: start; +} + +/* + * Label to the left of its field, the way the screen this replaces laid them out. A stacked label + * costs a whole row of height per field, and with 60-odd fields that is the difference between an + * operator seeing the form at once and scrolling through three screens of it. + */ +.pm-f { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + align-items: start; + column-gap: 7px; + + &--wide { + grid-column: span 2; + } +} + +.pm-f-lbl { + font-size: 11.5px; + line-height: 1.25; + text-align: right; + color: rgb(0 0 0 / 68%); + padding-top: 7px; + + // Required marker, in the same red the legacy screen used. + b { + color: #c1121f; + font-weight: 400; + margin-left: 2px; + } +} + +.pm-field { + width: 100%; +} + +.pm-checkbox-field { + display: flex; + align-items: center; + height: 56px; +} + +.pm-radio-group { + display: flex; + align-items: center; + gap: 16px; + height: 56px; +} + +.pm-add-nominee { + color: $primary; + height: fit-content; + align-self: center; +} + +/* + * The running total of the shares entered, beside the button that adds another. Reads as ordinary + * text while the split still adds up and turns to the warning colour when it does not, so the clerk + * sees the state of the whole nomination without having to try to save it. + */ +.pm-share-total { + align-self: center; + margin-left: 12px; + font-size: 13px; + color: rgb(0 0 0 / 60%); +} + +.pm-share-total-bad { + color: #b3261e; + font-weight: 500; +} + +.pm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +/* + * One nominee, laid out as fields that wrap rather than columns that must all fit. + * + * A fixed column per field only works while the count is fixed, and it is not - the Share % field + * appears and disappears with the split mode, and a tenth column pushed the remove button onto a + * line of its own. Each field instead states the width it needs to stay readable and the row breaks + * where it runs out of space, so nothing is squeezed and nothing is orphaned. + */ +.pm-nominee-row { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + align-items: start; + padding: 12px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } + + /* Names and addresses need room; a date, Yes/No or a percentage does not. */ + .pm-f { + flex: 1 1 170px; + min-width: 0; + } + + .pm-f--date { + flex: 0 1 140px; + } + + .pm-f--tight { + flex: 0 1 100px; + } +} + +.pm-remove-nominee { + color: #b91c1c; + margin-top: 8px; +} + +.pm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.pm-callout { + display: flex; + align-items: center; + gap: 10px; + margin: 0 24px; + padding: 12px 16px; + background: rgb(185 28 28 / 8%); + border: 1px solid rgb(185 28 28 / 25%); + border-radius: 6px; + color: #b91c1c; + font-size: 0.9rem; +} + +@media (width <= 1000px) { + .pm-grid { + grid-template-columns: repeat(2, 1fr); + } + +} + +/* Resolved name shown under a Client / Agent / Guardian id, as the legacy screen did in red. */ +.pm-hint--found { + color: #15803d; + font-weight: 500; +} + +.pm-hint--missing { + color: #b91c1c; + font-weight: 500; +} + +/* Explains why a block of fields is absent, so the screen does not just look unfinished. */ +.pm-note { + margin: 4px 0 0; + font-size: 0.8rem; + color: rgb(0 0 0 / 55%); + font-style: italic; +} + +.pm-closed-badge { + margin-left: 12px; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 60%); +} + +/* Says whose details a block of fields is for, so the screen explains itself. */ +.pm-section__sub { + flex: 1 1 auto; + min-width: 0; + font-size: 0.78rem; + font-weight: 400; + line-height: 1.45; + color: rgb(0 0 0 / 55%); +} + +.pm-search-ic { + color: rgb(0 0 0 / 38%); + font-size: 0.85rem; +} + +/* + * Money reads right to left, so a column of figures only lines up when it is right-aligned; the + * tabular figures keep every digit the same width so the decimal points stack. + */ +input.pm-money { + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* One row of a Client / Sub Type search list: the id, the name, and where it belongs. */ +.pm-opt { + display: flex; + align-items: baseline; + gap: 10px; + overflow: hidden; +} + +/* The "there are more" footer row. Quieter than a real option, since it is not one. */ +.pm-opt-more { + font-size: 0.75rem; + font-style: italic; + opacity: 0.75; +} + +.pm-opt-id { + flex: 0 0 auto; + min-width: 52px; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.78rem; + color: var(--pl-accent, #b45309); +} + +/* + The name is the reason the row exists, so it gets whatever width is going and is the last thing + to be cut. Before this the branch was fixed-width and the name carried the ellipsis, so in the + narrow panel every customer read as a single letter. +*/ + +.pm-opt-main { + flex: 1 1 auto; + min-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pm-opt-sub { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: rgb(0 0 0 / 50%); +} + +/* + * Dense data-entry layout. + * + * The screen this replaces fitted the whole form on one page - an officer opening accounts all day + * reads it at a glance and never scrolls. Material's default field height and spacing turn the same + * 56 fields into three screens' worth, so the whole card is tightened: shorter fields, smaller type, + * and just enough room under each field for the hint that names the client or agent it resolved. + */ +/* + * With the label moved outside, the box only has to hold the value - so it loses the height Material + * reserves for a floating label, and the form fits a screen instead of three. + */ +.pm-card { + ::ng-deep { + .mat-mdc-form-field { + font-size: 12.5px; + } + + .mat-mdc-form-field-infix { + min-height: 26px; + padding-top: 3px !important; + padding-bottom: 3px !important; + border-top: 0; + } + + .mat-mdc-text-field-wrapper { + padding-left: 8px; + padding-right: 8px; + } + + .mat-mdc-input-element, + .mat-mdc-select-value-text { + font-size: 12.5px; + } + + // Room for one line of hint - the resolved client/agent name that confirms the right record. + .mat-mdc-form-field-subscript-wrapper { + height: 14px; + min-height: 14px; + } + + .mat-mdc-form-field-hint { + font-size: 10.5px; + line-height: 14px; + } + + // Suffix icons and the datepicker toggle default to a 48px touch target, which reopens all the + // height just saved. + .mat-mdc-icon-button.mat-mdc-button-base { + width: 26px; + height: 26px; + padding: 3px; + } + + .mat-mdc-form-field-icon-suffix { + padding-left: 2px; + + .mat-datepicker-toggle svg { + width: 15px; + height: 15px; + } + } + + .mat-mdc-tab-body-wrapper { + min-height: 0; + } + } +} + +.pm-tab-content { + padding: 8px 16px; +} + +/* + * Blocks drawn the way the screen this replaces drew them: a hairline between groups of fields, + * with the group's name as a quiet caption above it rather than a heading bar of its own. An + * operator who has filled this form a hundred times navigates by field position; one filling their + * first still gets told which block is which. + */ +.pm-section { + padding: 8px 0 2px; + border-bottom: 1px solid rgb(0 0 0 / 11%); + + &__header { + margin-bottom: 6px; + gap: 0 10px; + + // Decoration the legacy screen did without, and the row is tighter for losing it. + i { + display: none; + } + + h3 { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: $primary; + } + } +} + +.pm-section__sub { + font-size: 0.72rem; + color: rgb(0 0 0 / 45%); +} + +.pm-grid { + gap: 0 12px; +} + +.pm-checkbox-field, +.pm-radio-group { + height: 28px; + + ::ng-deep label { + font-size: 12.5px; + } +} + +// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is +// no room for a label column eight fields across. +.pm-nominee-row { + gap: 0 10px; + padding: 3px 0; + + .pm-f { + grid-template-columns: minmax(0, 1fr); + row-gap: 1px; + } + + .pm-f-lbl { + text-align: left; + padding-top: 0; + font-size: 10.5px; + } +} + +.pm-note { + margin: 0; + font-size: 0.74rem; +} + +.pm-action-bar { + padding: 10px 18px; +} + +/* + The customer's real deposit account. Not a form field - nothing here is typed, it is what the + bank's books say - so it reads as a fact rather than sitting in an input box pretending to be one. +*/ +.pm-account { + display: flex; + flex-direction: column; + gap: 2px; + padding: 3px 0; + min-height: 26px; + justify-content: center; +} + +.pm-account-no { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.85rem; + letter-spacing: 0.02em; +} + +.pm-account-meta { + font-size: 0.75rem; + color: rgb(0 0 0 / 55%); +} + +.pm-account-live { + color: #15803d; + font-weight: 500; +} + +/* Said plainly: this party has no account behind it, which is a real gap, not a blank field. */ +.pm-account-missing { + font-size: 0.75rem; + font-style: italic; + color: #9b2c2c; +} + +/* + Something the branch has to do, rather than something that has gone wrong - so it reads as an + instruction in the warning colour, not an error in red like the closed-account notice. +*/ +.pm-callout--action { + background: rgb(180 83 9 / 8%); + border-color: rgb(180 83 9 / 25%); + color: #b45309; +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.ts b/src/app/deposits/party-master/create-party/create-party.component.ts new file mode 100644 index 000000000..24c97ea01 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.ts @@ -0,0 +1,1764 @@ +/** + * 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 { + UntypedFormBuilder, + UntypedFormGroup, + UntypedFormArray, + Validators, + ValidatorFn, + ValidationErrors, + AbstractControl +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatDialog } from '@angular/material/dialog'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { ProductsService } from 'app/products/products.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** Custom Components */ +import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component'; + +/** Custom Directives */ +import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; + +/** + * Party Master Entry form. + * + * The customer's DDS account - opened under a scheme (Sub Type, chosen from the DDS Account Type + * master) and assigned to the agent who will collect for it daily. Carries its own KYC, scheme + * terms, two-stage overdue flags and a nominee list. Modelled on the legacy Party Master Entry + * screen, field for field. + * + * The legacy screen filled the identity block from the Client ID and showed the agent's name beside + * the agent field; both are reproduced here, and the same lookup fills the guardian's name for a + * minor's account. That field read "Sub Agent" in the legacy system, which had a tier this one does + * not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or + * written by collections and the batch job - none of them are typed here. + * + * "Close Account" (command=close) permanently closes the Party once its balance is zero - the + * backend rejects the command otherwise. A closed Party can no longer be edited or receive new + * Party Tran collections. + * + * savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of + * the same client (resolved by account number, since that's what an officer has to hand) - + * clarified with the business (2026-08-24) as "link it if the client already has one, otherwise + * leave it". There is no unlink once linked. + */ +@Component({ + selector: 'mifosx-create-party', + templateUrl: './create-party.component.html', + styleUrls: ['./create-party.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabsModule, + MatCheckbox, + MatRadioModule, + MatAutocompleteModule, + // Holds the identifier fields to the shape their kind takes, at the keystroke. Imported here + // because a standalone component silently ignores a directive it has not imported - the + // attribute stays on the element and simply does nothing. + RestrictInputDirective + ] +}) +export class CreatePartyComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private organizationService = inject(OrganizationService); + private productsService = inject(ProductsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + private dialog = inject(MatDialog); + + /* + * What each identifier has to look like - the same shapes the server holds them to. + * + * Checked here as well so the clerk is told while the customer is still at the counter, rather + * than after a save that comes back rejected. The server checks them again regardless: a screen + * is a convenience, not a guarantee. + */ + private static readonly MOBILE_RE = /^[6-9][0-9]{9}$/; + private static readonly TELEPHONE_RE = /^[0-9+()\-\s]{6,20}$/; + private static readonly PINCODE_RE = /^[1-9][0-9]{5}$/; + /** Twelve digits, or the masked form CKYC hands back. */ + private static readonly AADHAAR_RE = /^([0-9]{12}|[Xx]{8}[0-9]{4})$/; + private static readonly PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/; + private static readonly CKYC_RE = /^[0-9]{14}$/; + + partyId: number | null = null; + + /** + * The earliest opening date the picker will offer on a new account - today, so the past is greyed + * out. The server refuses a past date on creation too; this only stops the clerk reaching for one. + * + * Null while editing. An account already on the books opened in the past by definition, so a floor + * of today would put every existing party into a state its own form would not accept. + */ + get minOpeningDate(): Date | null { + return this.partyId === null ? new Date() : null; + } + + selectedTabIndex = 0; + partyStatus = 'ACTIVE'; + linkedSavingsAccountId: number | null = null; + + /** + * The customer's real deposit account, as the bank's own books hold it. Read from the account + * itself rather than copied onto the party, so what shows here cannot drift from what is true. + */ + savingsAccountNo: string | null = null; + savingsProductName: string | null = null; + savingsAccountStatus: string | null = null; + savingsAccountBalance: number | null = null; + + /** + * A minor account holder who has come of age. The guardian's authority ended on that day, so the + * branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field. + */ + majorOn: string | null = null; + minorTurnedMajor = false; + + /** Shown beside the id fields once the record behind them has been resolved. */ + loadedClientName = ''; + clientLookupFailed = false; + loadedAgentName = ''; + agentLookupFailed = false; + loadedGuardianName = ''; + + /** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */ + clientOptions: any[] = []; + + /** + * The first page of clients, shown the moment the field is focused so the officer can pick from a + * list instead of having to know something to type. Fetched once and kept. + * + * Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank + * with far more later, and the field has to stay usable in both. Typing still searches every + * client, not just these - see searchClients. + */ + private initialClients: any[] = []; + private static readonly CLIENT_LIST_SIZE = 50; + + /** How many clients exist in total, to say so when the list below is only part of them. */ + clientTotal = 0; + + /** True while the options being shown are the opening list rather than search results. */ + showingClientList = false; + + get clientListIsPartial(): boolean { + return this.showingClientList && this.clientTotal > this.clientOptions.length; + } + guardianOptions: any[] = []; + /** The account types matching what has been typed into Sub Type. */ + subTypeOptions: any[] = []; + + /** + * The agents on offer. Every agent is fetched once and filtered here rather than asked of the + * server per keystroke - there is a handful of them, not a branch's worth of customers. + */ + agentOptions: any[] = []; + private allAgents: any[] = []; + + get isClosed(): boolean { + return this.partyStatus === 'CLOSED'; + } + + /** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */ + subTypes: any[] = []; + + operations = [ + 'Self', + 'Guardian', + 'Joint' + ]; + occupations = [ + 'Business', + 'Service', + 'Agriculture', + 'Homemaker', + 'Student', + 'Retired', + 'Other' + ]; + riskLevels = [ + 'Low', + 'Medium', + 'High' + ]; + adultMinorOptions = [ + 'Adult', + 'Minor' + ]; + withdrawTypes = [ + 'Cash', + 'Transfer', + 'Clearing' + ]; + freezeCodes = [ + 'Normal', + 'Debit Freeze', + 'Credit Freeze', + 'Total Freeze' + ]; + advCodes = [ + 'None', + 'Transfer To Saving', + 'Renew' + ]; + + /** The bank's own branches - where maturity proceeds can be credited. */ + /** + * Why a deposit gets held, offered as a list rather than typed. + * + * Free text meant the same reason arrived four ways - "Loan 1234", "loan no 1234", "LOAN#1234" - + * and anything counting them read four different reasons. Picked from a list they group properly, + * and the clerk types only where the list genuinely does not cover it. + */ + lienReasons = [ + 'Loan security', + 'Court order', + 'Tax attachment', + 'Deceased — claim pending', + 'Under investigation', + 'Other' + ]; + + offices: any[] = []; + officesError = ''; + + /** + * The customer's own savings accounts, for Credit To A/C. + * + * Picked rather than typed. The whole matured amount lands here years after somebody fills this + * in, and the live data showed what free text costs: one party carried "1234567890111", which is + * no account at all. Only savings accounts are offered - a fixed or recurring deposit cannot take + * the credit - and only this customer's own, which is what a maturity instruction means in any + * core system. The backend refuses anything else, whatever the screen sends. + */ + creditToAccounts: any[] = []; + + /** The Adv Code that makes the account say where its maturity proceeds should land. */ + private static readonly TRANSFER_TO_SAVING = 'Transfer To Saving'; + + /** Filled from the Client ID, as on the legacy screen. */ + private static readonly CLIENT_DERIVED_FIELDS = [ + 'partyName', + 'partyName1', + 'address1', + 'address2', + 'address3', + 'city', + 'pincode', + 'gender', + 'birthDate', + 'telephone', + 'mobileNoSms' + ]; + + private static readonly MINOR_FIELDS = [ + 'minorDate', + 'guardianClientId', + 'minAccountOpt', + 'relation' + ]; + + private static readonly CREDIT_TO_FIELDS = [ + 'creditToBranch', + 'creditToAccountType', + 'creditToAccount' + ]; + + partyForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + subType: [ + '', + Validators.required + ], + agentId: [ + '', + Validators.required + ], + // The branch this account belongs to. Its own, not the collecting agent's - an agent of one + // branch may hold accounts for customers of another, so it is asked for rather than inherited. + officeId: [ + '', + Validators.required + ], + savingsAccountNo: [''], + lastAccount: [ + { + value: '', + disabled: true + } + ], + partyCode: [ + { + value: '', + disabled: true + } + ], + nominal: [ + 'No', + Validators.required + ], + aadharNo: ['', Validators.pattern(CreatePartyComponent.AADHAAR_RE)], + partyName: [ + '', + Validators.required + ], + partyName1: [''], + address1: [ + '', + Validators.required + ], + address2: [''], + address3: [''], + city: [ + '', + Validators.required + ], + pincode: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.PINCODE_RE) + ] + ], + gender: [''], + panCardNo: ['', Validators.pattern(CreatePartyComponent.PAN_RE)], + telephone: ['', Validators.pattern(CreatePartyComponent.TELEPHONE_RE)], + mobileNoSms: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.MOBILE_RE) + ] + ], + operation: [ + 'Self', + Validators.required + ], + birthDate: [''], + occupation: [''], + risk: [''], + ckycId: ['', Validators.pattern(CreatePartyComponent.CKYC_RE)], + adultMinor: [ + 'Adult', + Validators.required + ], + // Enabled only for a minor's account - see applyAdultMinorRules(). + minorDate: [ + { + value: '', + disabled: true + } + ], + guardianClientId: [ + { + value: '', + disabled: true + } + ], + minAccountOpt: [ + { + value: '', + disabled: true + } + ], + relation: [ + { + value: '', + disabled: true + } + ], + openingDate: [ + '', + Validators.required + ], + month: [''], + // Worked out from the opening date and the tenure - never typed. + maturityDate: [ + { + value: '', + disabled: true + } + ], + reopenDate: [''], + depositAmount: [0], + dailyDepositAmount: [ + 0, + Validators.required + ], + days: [ + '', + Validators.required + ], + ledgerNo: [''], + maturityAmount: [ + { + value: 0, + disabled: true + } + ], + interestRate: [0], + withdrawType: [''], + // The overdue flags, balances and interest figures below are written by collections and the + // DDS batch job. They are shown so an officer can see where the account stands, never typed. + od1Flag: [ + { + value: false, + disabled: true + } + ], + od2Flag: [ + { + value: false, + disabled: true + } + ], + lien: ['No'], + // Asked for only while the lien is set - the rule is applied from the flag rather + // than stated here, so switching it off does not leave a required field behind. + lienReason: [''], + // What the clerk types when the list does not cover it. Kept apart from lienReason so the list + // stays a list - the one value the server stores is worked out at save. + lienReasonOther: [''], + payDate: [ + { + value: '', + disabled: true + } + ], + paymentAmount: [ + { + value: 0, + disabled: true + } + ], + currentBalance: [ + { + value: 0, + disabled: true + } + ], + openingBalance: [ + { + value: 0, + disabled: true + } + ], + isSmsActive: [true], + isEmailActive: [false], + interestCalc: [ + { + value: 0, + disabled: true + } + ], + interestDate: [ + { + value: '', + disabled: true + } + ], + spDescription: [''], + freezeCode: ['Normal'], + advCode: ['None'], + // Mandatory only when Adv Code is Transfer To Saving - see applyAdvCodeRules(). + creditToBranch: [''], + creditToAccountType: [''], + creditToAccount: [''], + nomineeApplicable: ['No'], + nomineeSplitMode: ['Percentage'], + nominees: this.formBuilder.array([]) + }); + + ngOnInit(): void { + this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => { + // A retired scheme takes no new accounts, but one already on it must still show when edited. + this.subTypes = (accountTypes || []).filter((accountType) => accountType.active); + this.subTypeOptions = this.subTypes; + }); + + // Branches are picked, never typed: a free-text branch cannot be matched back to a real office + // when the proceeds are actually moved. If the list cannot be fetched the reason is shown on the + // field - an empty dropdown with no explanation looks like the bank has no branches. + this.organizationService.getOffices().subscribe({ + next: (offices: any) => { + this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []; + this.officesError = this.offices.length === 0 ? 'No branches on file' : ''; + }, + error: (error: any) => { + this.officesError = error?.error?.defaultUserMessage || error?.error?.developerMessage + || `Could not load branches (${error?.status || 'no response'})`; + } + }); + + const routeId = this.route.snapshot.paramMap.get('partyId'); + if (routeId) { + this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party)); + } + + this.partyForm.get('days')?.addValidators(this.withinTerm()); + + this.wireClientLookup(); + this.wireSubTypeSearch(); + this.wireAgentLookup(); + this.wireGuardianLookup(); + this.wireConditionalRules(); + this.wireMaturityCalculation(); + } + + // ---------------------------------------------------------------- lookups + + /** + * Fills the identity block from the customer, the way the legacy screen did - except that the + * customer can be found by name as well as by id, since an officer at the counter is told a name. + * The control holds the picked client object, or whatever has been typed so far. + */ + private wireClientLookup(): void { + this.partyForm + .get('clientId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + // Always replace: changing the customer must not leave the previous one's details behind. + for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) { + this.partyForm.get(controlName)?.setValue(''); + } + this.loadedClientName = ''; + this.clientLookupFailed = false; + + // Picked from the list. + if (value && typeof value === 'object') { + this.clientOptions = []; + this.fillFromClient(value.id, value.displayName); + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + // Emptying the field puts the opening list back, rather than leaving nothing to pick from. + this.showClientList(); + return; + } + + // A bare number is an id typed straight in; anything else is a name to search for. A number + // can be both, so both are tried and whichever matches wins. + if (/^\d+$/.test(typed)) { + this.fillFromClient(typed, ''); + } + this.searchClients(typed); + }); + } + + private searchClients(term: string): void { + this.clientsService.getFilteredClients('displayName', 'ASC', false, term).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.showingClientList = false; + this.clientOptions = Array.isArray(found) ? found.slice(0, 25) : []; + }, + error: () => (this.clientOptions = []) + }); + } + + /** + * Opens the customer list when the field is focused. + * + * Without this the officer has to know something before anything appears, which is backwards: the + * point of the field is to find a customer, and a blank box is no help in doing that. A customer + * already picked is left alone - reopening the list over a chosen name only invites a misclick. + */ + onClientFocus(): void { + if (this.partyForm.get('clientId')?.value) { + return; + } + this.showClientList(); + } + + /** Shows the opening list, fetching it the first time it is asked for. */ + private showClientList(): void { + if (this.initialClients.length > 0) { + this.showingClientList = true; + this.clientOptions = this.initialClients; + return; + } + + this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + this.showingClientList = true; + this.clientOptions = this.initialClients; + }, + // A failed list is not a failed screen - the officer can still type a name or an id. + error: () => (this.clientOptions = []) + }); + } + + private fillFromClient(clientId: string | number, knownName: string): void { + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + this.loadedClientName = client?.displayName || knownName || ''; + this.clientLookupFailed = false; + this.setField('partyName', client?.displayName); + this.setField('mobileNoSms', client?.mobileNo); + this.setField('gender', client?.gender?.name || client?.gender); + const dateOfBirth = this.toDate(client?.dateOfBirth); + if (dateOfBirth) { + this.partyForm.get('birthDate')?.setValue(dateOfBirth); + } + // Proceeds usually go back to an account at the customer's own branch, so that is offered + // first - any branch can still be picked, since the account may well be at another. + const creditToBranch = this.partyForm.get('creditToBranch'); + if (client?.officeName && !creditToBranch?.value) { + creditToBranch?.setValue(client.officeName, { emitEvent: false }); + } + + this.loadClientAddress(String(clientId)); + this.loadClientIdentifiers(String(clientId)); + this.loadCreditToAccounts(String(clientId)); + }, + error: () => { + this.clientLookupFailed = true; + } + }); + } + + /** + * The customer's own accounts a maturity may be credited to. + * + * Worked out by the server, which applies the same conditions the payout enforces years later: an + * active savings account of theirs, not a fixed or recurring deposit, and not a DDS scheme account + * - those are collected on daily and pay out at their own maturity, so naming one here would send + * this maturity into another collection account instead of back to the customer. + * + * Deciding it here instead let the list and the refusal drift apart, and the accounts on offer + * came to include a scheme account of a party that had already closed. + */ + private loadCreditToAccounts(clientId: string): void { + this.organizationService.getCreditableAccounts(clientId).subscribe({ + next: (accounts: any) => (this.creditToAccounts = accounts || []), + error: () => (this.creditToAccounts = []) + }); + } + + /** + * Fills the account type from the account chosen. An account already is of a type, and asking for + * both lets the two disagree - the account is the half that is actually true. + */ + onCreditToAccountPicked(accountNo: string): void { + const account = this.creditToAccounts.find((each: any) => each.accountNo === accountNo); + if (account?.productName) { + this.partyForm.get('creditToAccountType')?.setValue(account.productName); + } + } + + /** + * Aadhar, PAN and CKYC are not plain client fields - they are KYC identifiers filed against the + * client, each tagged with a document type. They are matched by that type's name so the screen + * shows the same numbers the client record already holds, rather than asking for them twice. + */ + private loadClientIdentifiers(clientId: string): void { + this.clientsService.getClientIdentifiers(clientId).subscribe({ + next: (identifiers: any) => { + for (const identifier of Array.isArray(identifiers) ? identifiers : []) { + const words = this.wordsOf(identifier?.documentType?.name); + const documentKey = identifier?.documentKey; + if (!documentKey) { + continue; + } + if (words.includes('aadhaar') || words.includes('aadhar')) { + this.setField('aadharNo', documentKey); + } else if (words.includes('pan')) { + this.setField('panCardNo', documentKey); + } else if (words.includes('ckyc')) { + this.setField('ckycId', documentKey); + } + } + }, + // A client with no identifiers on file is normal - the officer just types them. + error: () => undefined + }); + } + + /** What the Client ID box shows for a picked client, rather than "[object Object]". */ + displayClient = (value: any): string => { + if (!value) { + return ''; + } + return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value); + }; + + private loadClientAddress(clientId: string): void { + this.clientsService.getClientAddressData(clientId, true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + this.setField('address1', address.addressLine1 || address.street); + this.setField('address2', address.addressLine2); + this.setField('address3', address.addressLine3); + this.setField('city', address.city); + this.setField('pincode', address.postalCode); + }, + // A client with no address on file is normal - the officer just types it. + error: () => undefined + }); + } + + /** Shows the collecting agent's name beside the id, as the legacy screen did in red. */ + private wireAgentLookup(): void { + this.partyForm + .get('agentId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + this.loadedAgentName = ''; + this.agentLookupFailed = false; + + // Picked from the list - the agent is already in hand, nothing to look up. + if (value && typeof value === 'object') { + this.agentOptions = []; + this.loadedAgentName = value.agentNameId || value.clientName || ''; + this.agentLookupFailed = !value.active; + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + this.showAgentList(); + return; + } + + this.filterAgents(typed); + + // A bare number is an id typed straight in, the way this field always worked. + if (!/^\d+$/.test(typed)) { + return; + } + this.organizationService.getAgent(typed).subscribe({ + next: (agent: any) => { + this.loadedAgentName = agent?.agentNameId || agent?.clientName || ''; + this.agentLookupFailed = !agent?.active; + }, + error: () => { + this.agentLookupFailed = true; + } + }); + }); + } + + /** + * Opens the agent list on focus, so the field can be answered by picking rather than by knowing + * an id. A chosen agent is left alone - reopening over it only invites a misclick. + */ + onAgentFocus(): void { + if (this.partyForm.get('agentId')?.value) { + return; + } + this.showAgentList(); + } + + private showAgentList(): void { + if (this.allAgents.length > 0) { + this.agentOptions = this.allAgents; + return; + } + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.allAgents = agents || []; + this.agentOptions = this.allAgents; + }, + // A failed list is not a failed screen - an id can still be typed. + error: () => (this.agentOptions = []) + }); + } + + /** Matches on code or name, since an officer may know either. */ + private filterAgents(term: string): void { + const needle = term.toLowerCase(); + const match = (agent: any) => + String(agent.agentCode || '').toLowerCase().includes(needle) || + String(agent.agentNameId || '').toLowerCase().includes(needle) || + String(agent.id) === term; + + if (this.allAgents.length > 0) { + this.agentOptions = this.allAgents.filter(match); + return; + } + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.allAgents = agents || []; + this.agentOptions = this.allAgents.filter(match); + }, + error: () => (this.agentOptions = []) + }); + } + + displayAgent = (value: any): string => { + if (!value) { + return ''; + } + return typeof value === 'object' ? `${value.agentCode} — ${value.agentNameId}` : String(value); + }; + + /** A minor's account names a guardian, found the same way the customer is. */ + private wireGuardianLookup(): void { + this.partyForm + .get('guardianClientId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + this.loadedGuardianName = ''; + + if (value && typeof value === 'object') { + this.guardianOptions = []; + this.loadedGuardianName = value.displayName || ''; + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + this.guardianOptions = []; + return; + } + + if (/^\d+$/.test(typed)) { + this.clientsService.getClientData(typed, true).subscribe({ + next: (client: any) => (this.loadedGuardianName = client?.displayName || ''), + error: () => undefined + }); + } + this.clientsService.getFilteredClients('displayName', 'ASC', false, typed).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.guardianOptions = Array.isArray(found) ? found.slice(0, 25) : []; + }, + error: () => (this.guardianOptions = []) + }); + }); + } + + /** + * Sub Type is picked from the DDS Account Type master. Once a bank runs a dozen schemes a plain + * dropdown is a scroll, so it filters on either the code or the scheme's name as you type. + */ + private wireSubTypeSearch(): void { + this.partyForm.get('subType')?.valueChanges.subscribe((value) => { + const term = String(value ?? '').toLowerCase().trim(); + this.subTypeOptions = !term + ? this.subTypes + : this.subTypes.filter( + (type) => + String(type.accountTypeCode || '').toLowerCase().includes(term) || + String(type.accountTypeDescription || '').toLowerCase().includes(term) || + String(type.accountTypeGroup || '').toLowerCase().includes(term) + ); + }); + } + + /** Opening the box should offer everything, not just what the current value happens to match. */ + showAllSubTypes(): void { + this.subTypeOptions = this.subTypes; + } + + displaySubType = (code: any): string => { + if (!code) { + return ''; + } + const found = this.subTypes.find((type) => type.accountTypeCode === code); + return found ? `${found.accountTypeDescription} (${found.accountTypeCode})` : String(code); + }; + + // ----------------------------------------------------------- conditionals + + private wireConditionalRules(): void { + this.partyForm.get('adultMinor')?.valueChanges.subscribe(() => this.applyAdultMinorRules()); + this.partyForm.get('advCode')?.valueChanges.subscribe(() => this.applyAdvCodeRules()); + this.partyForm.get('lien')?.valueChanges.subscribe(() => this.applyLienRules()); + this.partyForm.get('lienReason')?.valueChanges.subscribe(() => this.applyLienRules()); + this.partyForm.get('nomineeApplicable')?.valueChanges.subscribe(() => this.applyNomineeRules()); + // Switching the split re-rules every nominee already on the form, not just the ones added after + // - a clerk who names three nominees and then picks Percentage must still be asked for shares. + this.partyForm.get('nomineeSplitMode')?.valueChanges.subscribe((mode) => { + this.nominees.controls.forEach((group) => this.applySplitModeTo(group, mode)); + }); + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + } + + /** + * A minor's account has to say when the holder comes of age and who operates it until then; on an + * adult's account those four fields are meaningless, so they stay disabled and empty. + */ + private applyAdultMinorRules(): void { + const isMinor = this.partyForm.get('adultMinor')?.value === 'Minor'; + for (const controlName of CreatePartyComponent.MINOR_FIELDS) { + const control = this.partyForm.get(controlName); + if (!control) { + continue; + } + if (isMinor) { + control.enable({ emitEvent: false }); + // minAccountOpt is an optional note on how the account may be operated. + if (controlName !== 'minAccountOpt') { + control.setValidators(Validators.required); + } + } else { + control.clearValidators(); + control.setValue('', { emitEvent: false }); + control.disable({ emitEvent: false }); + this.loadedGuardianName = ''; + } + control.updateValueAndValidity({ emitEvent: false }); + } + } + + /** + * Transfer To Saving means the maturity proceeds land in a savings account, so the account has to + * name which one. Any other Adv Code leaves nothing to credit, and the three fields go away. + */ + private applyAdvCodeRules(): void { + const transfers = this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING; + for (const controlName of CreatePartyComponent.CREDIT_TO_FIELDS) { + const control = this.partyForm.get(controlName); + if (!control) { + continue; + } + if (transfers) { + control.setValidators(Validators.required); + } else { + control.clearValidators(); + control.setValue('', { emitEvent: false }); + } + control.updateValueAndValidity({ emitEvent: false }); + } + } + + get creditToRequired(): boolean { + return this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING; + } + + get isMinorAccount(): boolean { + return this.partyForm.get('adultMinor')?.value === 'Minor'; + } + + get nomineesRequired(): boolean { + return this.partyForm.get('nomineeApplicable')?.value === 'Yes'; + } + + + + /** + * Which entry in the list a stored reason belongs to. + * + * Anything not on the list was typed under "Other", including reasons saved before there was a + * list - so it comes back as "Other" with the original words intact rather than being lost to a + * dropdown that has no room for it. + */ + private listedLienReason(stored: string | null | undefined): string { + if (!stored) { + return ''; + } + return this.lienReasons.includes(stored) ? stored : 'Other'; + } + + /** True while this account's money is being held, which is what asks for a reason. */ + get lienHeld(): boolean { + return this.partyForm?.get('lien')?.value === 'Yes'; + } + + /** + * A held account has to say what it is held for; a free one has nothing to explain. + * + * The reason is cleared when the hold is lifted rather than left behind, so the form never reads + * "Lien: No" beside an explanation of a hold that no longer exists - the server clears it for the + * same reason. + */ + private applyLienRules(): void { + const reason = this.partyForm.get('lienReason'); + const other = this.partyForm.get('lienReasonOther'); + if (!reason || !other) { + return; + } + + if (this.lienHeld) { + reason.setValidators(Validators.required); + } else { + reason.clearValidators(); + reason.setValue('', { emitEvent: false }); + } + reason.updateValueAndValidity({ emitEvent: false }); + + // Only "Other" asks for words of its own - picking it and leaving it blank says no more than + // leaving the whole thing blank did. + if (this.lienHeld && this.lienReasonIsOther) { + other.setValidators([ + Validators.required, + Validators.maxLength(200) + ]); + } else { + other.clearValidators(); + other.setValue('', { emitEvent: false }); + } + other.updateValueAndValidity({ emitEvent: false }); + } + + /** True while the clerk has chosen to write the reason themselves. */ + get lienReasonIsOther(): boolean { + return this.partyForm?.get('lienReason')?.value === 'Other'; + } + + /** Saying an account has a nominee but naming none would leave the nomination incomplete. */ + private applyNomineeRules(): void { + if (this.nomineesRequired && this.nominees.length === 0) { + this.addNominee(); + } + if (!this.nomineesRequired) { + this.nominees.clear(); + } + } + + // ----------------------------------------------------------- calculations + + /** + * Maturity date and amount follow from the opening date and the tenure, so they are recomputed + * whenever one of those changes rather than being typed - which is why the legacy screen greyed + * them out. + */ + private wireMaturityCalculation(): void { + // The term drives everything downstream, so changing it refills Days as well. + for (const controlName of ['openingDate', 'month']) { + this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm()); + } + // Days typed by hand stands: it only moves the maturity date when no month was given. + this.partyForm.get('days')?.valueChanges.subscribe(() => { + this.recalculateMaturityDate(); + this.recalculateMaturityAmount(); + }); + for (const controlName of ['dailyDepositAmount', 'interestRate']) { + this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount()); + } + } + + private recalculateTerm(): void { + this.recalculateMaturityDate(); + // Days is not refilled from the term: whether collection runs every day or skips Sundays and + // holidays is the bank's own rule, and guessing it would quietly put a wrong instalment count - + // and so a wrong maturity amount - in front of the customer. It is only checked, below. + this.partyForm.get('days')?.updateValueAndValidity({ emitEvent: false }); + this.recalculateMaturityAmount(); + } + + private recalculateMaturityDate(): void { + const raw = this.partyForm.getRawValue(); + const openingDate = this.toDate(raw.openingDate); + const months = Number(raw.month) || 0; + const days = Number(raw.days) || 0; + + if (!openingDate || (months <= 0 && days <= 0)) { + this.partyForm.get('maturityDate')?.setValue('', { emitEvent: false }); + return; + } + + const maturity = new Date(openingDate.getTime()); + if (months > 0) { + maturity.setMonth(maturity.getMonth() + months); + } else { + maturity.setDate(maturity.getDate() + days); + } + this.partyForm.get('maturityDate')?.setValue(maturity, { emitEvent: false }); + } + + /** Calendar days the term covers, once a term in months has been given. */ + get termDays(): number | null { + const raw = this.partyForm.getRawValue(); + const openingDate = this.toDate(raw.openingDate); + const months = Number(raw.month) || 0; + if (!openingDate || months <= 0) { + return null; + } + const maturity = new Date(openingDate.getTime()); + maturity.setMonth(maturity.getMonth() + months); + return Math.round((maturity.getTime() - openingDate.getTime()) / (24 * 60 * 60 * 1000)); + } + + /** + * More instalments than there are days in the term is not a bank rule to be checked, it is + * arithmetic: 365 daily collections cannot happen inside three months. Left unchecked it puts a + * three-month maturity date beside a maturity amount worked out over a year, and the customer is + * quoted a figure the account will never reach. + * + * Fewer instalments than days is normal and passes silently - that is just a bank that does not + * collect on Sundays and holidays. + */ + private withinTerm(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const term = this.termDays; + const days = Number(control.value) || 0; + if (term === null || days <= 0 || days <= term) { + return null; + } + return { exceedsTerm: { term } }; + }; + } + + private recalculateMaturityAmount(): void { + const raw = this.partyForm.getRawValue(); + this.partyForm + .get('maturityAmount') + ?.setValue( + this.maturityAmountOf(Number(raw.dailyDepositAmount) || 0, Number(raw.days) || 0, Number(raw.interestRate) || 0), + { emitEvent: false } + ); + } + + /** + * Standard recurring-deposit simple interest: each daily instalment earns for the time left to + * maturity, so the first earns on the full term and the last on none. That averages out to + * n(n+1)/2 instalment-days of interest on top of the n instalments themselves. + * + * At a zero rate this is simply the total of the instalments. + */ + private maturityAmountOf(dailyAmount: number, days: number, ratePercent: number): number { + if (dailyAmount <= 0 || days <= 0) { + return 0; + } + const principal = dailyAmount * days; + const interest = ((dailyAmount * days * (days + 1)) / 2) * (ratePercent / 100) * (1 / 365); + return Math.round((principal + interest) * 100) / 100; + } + + // ---------------------------------------------------------------- nominees + + get nominees(): UntypedFormArray { + return this.partyForm.get('nominees') as UntypedFormArray; + } + + private buildNomineeGroup(): UntypedFormGroup { + const group = this.formBuilder.group({ + nominationDate: [ + '', + Validators.required + ], + name: [ + '', + Validators.required + ], + // A nominee the bank cannot reach, or whose age it does not know, is a name and nothing more - + // and the age is what decides whether a guardian has to be named at all. + mobileNo: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.MOBILE_RE) + ] + ], + birthDate: [ + '', + Validators.required + ], + relation: [ + '', + Validators.required + ], + gender: [ + '', + Validators.required + ], + guardianName: [''], + // One of these belongs to each split and neither to both: a share under Percentage, a place + // in the order under Sequence. Required-ness is set from the split mode rather than here, so + // switching the mode does not leave a stale rule behind. + sharePercent: [''], + sequenceNo: [''] + }); + + this.applySplitModeTo(group, this.partyForm?.get('nomineeSplitMode')?.value); + + // A nominee who is a minor cannot receive the proceeds themselves, so a guardian is named. + // Driven by the date of birth, which is also what decides whether they are a minor at all. The + // clerk is asked for the date once and the rest follows, rather than being asked a second time + // in a way that can contradict the first. + const applyGuardianRule = () => { + const guardian = group.get('guardianName'); + if (!guardian) { + return; + } + if (this.isNomineeMinor(group)) { + guardian.setValidators(Validators.required); + } else { + guardian.clearValidators(); + guardian.setValue('', { emitEvent: false }); + } + guardian.updateValueAndValidity({ emitEvent: false }); + }; + + // Both dates decide it: being under age is the gap between them, so a correction to either can + // turn a nominee into a minor or out of one. + group.get('birthDate')?.valueChanges.subscribe(applyGuardianRule); + group.get('nominationDate')?.valueChanges.subscribe(applyGuardianRule); + + return group; + } + + addNominee(): void { + this.nominees.push(this.buildNomineeGroup()); + } + + removeNominee(index: number): void { + this.nominees.removeAt(index); + } + + /** True while the deposit is being split by share rather than by order of entitlement. */ + get splitByPercentage(): boolean { + return this.partyForm?.get('nomineeSplitMode')?.value === 'Percentage'; + } + + /** What the shares entered so far come to, so the clerk can see the remainder as they type. */ + get nomineeShareTotal(): number { + return this.nominees.controls.reduce((sum, group) => sum + Number(group.get('sharePercent')?.value || 0), 0); + } + + /** + * A nomination that does not account for the whole deposit names nobody for the remainder, so the + * form will not submit one. Shown as a running total rather than only on save, because a clerk + * who has typed four shares wants to know which one to change, not that the set is wrong. + * + * Silent until at least one share has been typed. A blank form has nothing wrong with it yet, and + * opening the tab to a red warning tells the clerk off for work they have not had a chance to do. + */ + get nomineeShareError(): string | null { + if (!this.splitByPercentage || this.nominees.length === 0 || !this.anyShareEntered) { + return null; + } + const total = this.nomineeShareTotal; + if (total === 100) { + return null; + } + const shortBy = Math.round((100 - total) * 100) / 100; + return shortBy > 0 ? `${shortBy}% still to allocate` : `${Math.abs(shortBy)}% over`; + } + + /** Whether the clerk has started allocating at all, which is what turns the total into a warning. */ + private get anyShareEntered(): boolean { + return this.nominees.controls.some((group) => String(group.get('sharePercent')?.value ?? '').trim() !== ''); + } + + /** True while the deposit passes down an order of entitlement rather than being split by share. */ + get splitBySequence(): boolean { + return this.partyForm?.get('nomineeSplitMode')?.value === 'Sequence'; + } + + /** + * That every place in the order is filled exactly once. + * + * Two nominees given the same place leaves the bank to choose between them, and a gap leaves + * nobody at that place - both are the order failing to say what it exists to say. Checked across + * the whole list, because 1 and 3 are each perfectly valid on their own. + */ + get nomineeOrderError(): string | null { + if (!this.splitBySequence || this.nominees.length === 0) { + return null; + } + const entered = this.nominees.controls + .map((group) => String(group.get('sequenceNo')?.value ?? '').trim()) + .filter((value) => value !== '') + .map(Number); + + if (entered.length === 0) { + return null; + } + const duplicate = entered.find((value, index) => entered.indexOf(value) !== index); + if (duplicate !== undefined) { + return `Order ${duplicate} is used twice.`; + } + if (entered.length < this.nominees.length) { + return 'Every nominee needs a place in the order.'; + } + const missing = Array.from({ length: this.nominees.length }, (_, i) => i + 1).filter((n) => !entered.includes(n)); + return missing.length > 0 ? `Order ${missing.join(', ')} is missing.` : null; + } + + /** + * Turns on whichever of the two the split actually uses, and clears the other. + * + * Leaving the unused one populated would send the server a share on a Sequence nomination, or an + * order on a Percentage one - a second answer to a question that split does not ask. + */ + private applySplitModeTo(group: AbstractControl, splitMode: string): void { + const share = group.get('sharePercent'); + const order = group.get('sequenceNo'); + const byPercentage = splitMode === 'Percentage'; + + if (share) { + if (byPercentage) { + share.setValidators([ + Validators.required, + Validators.min(0.01), + Validators.max(100) + ]); + } else { + share.clearValidators(); + share.setValue('', { emitEvent: false }); + } + share.updateValueAndValidity({ emitEvent: false }); + } + + if (order) { + if (byPercentage) { + order.clearValidators(); + order.setValue('', { emitEvent: false }); + } else { + order.setValidators([ + Validators.required, + Validators.min(1) + ]); + } + order.updateValueAndValidity({ emitEvent: false }); + } + } + + /** + * Whether this nominee was under age on the day they were nominated, worked out from their date + * of birth rather than from a separate Yes/No. + * + * Measured against the nomination date, not today, so the form agrees with what the server + * stores: a nominee who has since come of age was still a minor on the day they were named, and + * the guardian named beside them belongs to that day. + */ + isNomineeMinor(control: AbstractControl): boolean { + const birth = control.get('birthDate')?.value; + const nominated = control.get('nominationDate')?.value; + if (!birth || !nominated) { + return false; + } + const birthDate = new Date(birth); + const majorityOn = new Date(nominated); + majorityOn.setFullYear(majorityOn.getFullYear() - 18); + return birthDate > majorityOn; + } + + // ------------------------------------------------------------------ actions + + cancel(): void { + this.partyId = null; + this.partyStatus = 'ACTIVE'; + this.linkedSavingsAccountId = null; + this.savingsAccountNo = null; + this.savingsProductName = null; + this.savingsAccountStatus = null; + this.savingsAccountBalance = null; + this.majorOn = null; + this.minorTurnedMajor = false; + this.loadedClientName = ''; + this.loadedAgentName = ''; + this.loadedGuardianName = ''; + this.clientLookupFailed = false; + this.agentLookupFailed = false; + this.partyForm.reset(); + this.partyForm.patchValue({ + nominal: 'No', + operation: 'Self', + adultMinor: 'Adult', + lien: 'No', + lienReason: '', + isSmsActive: true, + isEmailActive: false, + freezeCode: 'Normal', + advCode: 'None', + nomineeApplicable: 'No', + nomineeSplitMode: 'Percentage', + currentBalance: 0, + openingBalance: 0, + maturityAmount: 0, + interestRate: 0 + }); + this.nominees.clear(); + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + } + + exit(): void { + this.router.navigate(['/accounts/dds']); + } + + save(): void { + const raw = this.partyForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined); + + const payload: any = { + // Either a client picked from the search list or an id typed straight in. + clientId: this.idOf(raw.clientId), + // idOf, not Number: the control now holds the picked agent object, and Number({}) is NaN. + agentId: this.idOf(raw.agentId), + officeId: raw.officeId, + savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined, + subType: raw.subType, + lastAccount: raw.lastAccount || undefined, + nominal: raw.nominal === 'Yes', + aadharNo: raw.aadharNo || undefined, + partyName: raw.partyName, + partyName1: raw.partyName1 || undefined, + address1: raw.address1, + address2: raw.address2 || undefined, + address3: raw.address3 || undefined, + city: raw.city, + pincode: raw.pincode, + gender: raw.gender || undefined, + panCardNo: raw.panCardNo || undefined, + telephone: raw.telephone || undefined, + mobileNoSms: raw.mobileNoSms, + operation: raw.operation, + birthDate: asDate(raw.birthDate), + occupation: raw.occupation || undefined, + risk: raw.risk || undefined, + ckycId: raw.ckycId || undefined, + adultMinor: raw.adultMinor, + minorDate: asDate(raw.minorDate), + guardianClientId: raw.guardianClientId ? this.idOf(raw.guardianClientId) : undefined, + minAccountOpt: raw.minAccountOpt || undefined, + relation: raw.relation || undefined, + openingDate: asDate(raw.openingDate), + month: raw.month || undefined, + reopenDate: asDate(raw.reopenDate), + depositAmount: raw.depositAmount || undefined, + dailyDepositAmount: raw.dailyDepositAmount, + days: raw.days, + ledgerNo: raw.ledgerNo || undefined, + interestRate: raw.interestRate || 0, + withdrawType: raw.withdrawType || undefined, + lien: raw.lien === 'Yes', + // Sent only when the money is actually held. Releasing it clears the reason on the + // server, so a released account does not carry an explanation of a hold it no + // longer has. + lienReason: raw.lien === 'Yes' ? (raw.lienReason === 'Other' ? raw.lienReasonOther : raw.lienReason) : undefined, + isSmsActive: !!raw.isSmsActive, + isEmailActive: !!raw.isEmailActive, + spDescription: raw.spDescription || undefined, + freezeCode: raw.freezeCode || undefined, + advCode: raw.advCode || undefined, + creditToBranch: raw.creditToBranch || undefined, + creditToAccountType: raw.creditToAccountType || undefined, + creditToAccount: raw.creditToAccount || undefined, + nomineeApplicable: raw.nomineeApplicable === 'Yes', + nomineeSplitMode: raw.nomineeSplitMode || undefined, + dateFormat, + locale, + nominees: (raw.nominees || []).map((nominee: any) => ({ + // Sent through the same asDate as every other date on this form. A payload states one + // dateFormat and the server reads all of it that way, so a nominee date written in a second + // format is one the server is right to refuse. + nominationDate: asDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo || undefined, + birthDate: asDate(nominee.birthDate), + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName || undefined, + // Only the one its split actually uses is sent. The other is left out entirely, so the + // record says it was not stated rather than carrying a zero the server would have to tell + // apart from a real entitlement or a real place in the order. + sharePercent: this.splitByPercentage ? Number(nominee.sharePercent) : undefined, + sequenceNo: this.splitBySequence ? Number(nominee.sequenceNo) : undefined + })) + }; + + if (this.partyId) { + this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit()); + } else { + this.organizationService.createParty(payload).subscribe(() => this.exit()); + } + } + + closeAccount(): void { + if (!this.partyId) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .closeParty(this.partyId, { + closeDate: this.dateUtils.formatDate(new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe(() => this.exit()); + } + + /** + * Whether this account can still be closed before its term is up. + * + * Only while the maturity date is ahead: once it has passed the account is paid out and closed on + * its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not + * checked here - that period lives on the scheme, and a copy of the rule on this screen would drift + * from the one the server applies. An account inside its lock-in is offered the button and refused + * with the date it becomes closable, which tells the officer more than a missing button would. + */ + /** + * The client and agent as ids the API understands. Both fields are autocompletes, so each holds + * either the object picked from the list or the raw text typed into it - the dialog needs the + * client to look up which accounts can take the payout, and the agent's code only to name who is + * being charged. + */ + private get clientIdOfForm(): any { + const value = this.partyForm.get('clientId')?.value; + return value && typeof value === 'object' ? value.id : value; + } + + private get agentCodeOfForm(): string { + const value = this.partyForm.get('agentId')?.value; + return value && typeof value === 'object' ? value.agentCode : String(value ?? ''); + } + + get canCloseEarly(): boolean { + const maturity = this.partyForm.get('maturityDate')?.value; + if (!maturity) { + return false; + } + const matured = maturity instanceof Date ? maturity : new Date(maturity); + return !isNaN(matured.getTime()) && matured.getTime() > Date.now(); + } + + /** + * Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the + * agent carries the cost - and shows what the scheme charges for each. Everything else, including + * the lock-in, is applied by the server. + */ + closeEarly(): void { + if (!this.partyId) { + return; + } + const dialogRef = this.dialog.open(PrematureCloseDialogComponent, { + data: { + partyCode: this.partyForm.get('partyCode')?.value, + partyName: this.partyForm.get('partyName')?.value, + subType: this.partyForm.get('subType')?.value, + interestRate: this.partyForm.get('interestRate')?.value, + maturityDate: this.partyForm.get('maturityDate')?.value, + currentBalance: this.partyForm.get('currentBalance')?.value, + clientId: this.clientIdOfForm, + agentCode: this.agentCodeOfForm + }, + width: '560px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((closeCommand: any) => { + if (!closeCommand) { + return; + } + // The date goes up the way every other date on this system does - formatted to the user's own + // setting, with that setting and their locale alongside so the server reads it back the same + // way. An ordinary close sends it like this too; two ways of closing an account should not + // disagree about something as plain as what day it is. + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .prematureCloseParty(this.partyId as number, { + ...closeCommand, + closeDate: this.dateUtils.formatDate(new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe(() => this.exit()); + // A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's + // own error handler, which names the reason. Repeating it here would say the same thing twice. + }); + } + + // ------------------------------------------------------------------ helpers + + /** + * Splits a document type's name into lower-case words, so an identifier can be recognised by a + * whole word rather than a substring. + * + * Banks name these types freely - the records here carry "PAN Card" and "Aadhaar Card", while the + * code list also offers bare "PAN" and "AADHAAR" - so an exact-name match misses them. A substring + * match is worse: "COMPANY EMP ID" contains "pan", and would be filed as the customer's PAN. + */ + private wordsOf(name: any): string[] { + return String(name || '') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 0); + } + + /** + * Indian digit grouping - 12,34,567.89 rather than 1,234,567.89. An officer reading the first + * says "twelve lakh" at a glance; the second has to be counted. + */ + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + /** + * A figure the system keeps rather than the officer types, shown as money: grouped, always to + * paise, and right-aligned so a column of them lines up. The control keeps the plain number. + */ + money(controlName: string): string { + const value = Number(this.partyForm.get(controlName)?.value); + return Number.isFinite(value) ? CreatePartyComponent.MONEY.format(value) : ''; + } + + /** A client field holds either the record picked from the search list or an id typed straight in. */ + private idOf(value: any): number { + return Number(value && typeof value === 'object' ? value.id : value); + } + + private setField(controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + this.partyForm.get(controlName)?.setValue(String(value)); + } + + /** Fineract hands dates back as [yyyy, mm, dd] in places and as a string in others. */ + private toDate(value: any): Date | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return value; + } + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + + private patchFormFromParty(party: any): void { + this.partyId = party.id; + this.partyStatus = party.status || 'ACTIVE'; + this.linkedSavingsAccountId = party.savingsAccountId || null; + this.savingsAccountNo = party.savingsAccountNo || null; + this.savingsProductName = party.savingsProductName || null; + this.savingsAccountStatus = party.savingsAccountStatus || null; + this.savingsAccountBalance = party.savingsAccountBalance ?? null; + this.majorOn = party.majorOn || null; + this.minorTurnedMajor = party.minorTurnedMajor === true; + this.loadedClientName = party.clientName || ''; + + // Needed for the Credit To A/C dropdown to show the saved account as chosen rather than blank, + // so it is fetched on edit as well as when a client is first looked up. + if (party.clientId) { + this.loadCreditToAccounts(String(party.clientId)); + } + this.loadedAgentName = party.agentName || ''; + this.partyForm.patchValue({ + clientId: party.clientId, + subType: party.subType, + agentId: party.agentId, + officeId: party.officeId, + partyCode: party.partyCode, + lastAccount: party.lastAccount, + nominal: party.nominal ? 'Yes' : 'No', + aadharNo: party.aadharNo, + partyName: party.partyName, + partyName1: party.partyName1, + address1: party.address1, + address2: party.address2, + address3: party.address3, + city: party.city, + pincode: party.pincode, + gender: party.gender, + panCardNo: party.panCardNo, + telephone: party.telephone, + mobileNoSms: party.mobileNoSms, + operation: party.operation, + birthDate: this.toDate(party.birthDate), + occupation: party.occupation, + risk: party.risk, + ckycId: party.ckycId, + adultMinor: party.adultMinor, + minorDate: this.toDate(party.minorDate), + guardianClientId: party.guardianClientId, + minAccountOpt: party.minAccountOpt, + relation: party.relation, + openingDate: this.toDate(party.openingDate), + month: party.month, + maturityDate: this.toDate(party.maturityDate), + reopenDate: this.toDate(party.reopenDate), + depositAmount: party.depositAmount, + dailyDepositAmount: party.dailyDepositAmount, + days: party.days, + ledgerNo: party.ledgerNo, + maturityAmount: party.maturityAmount, + interestRate: party.interestRate, + withdrawType: party.withdrawType, + od1Flag: party.od1Flag, + od2Flag: party.od2Flag, + lien: party.lien ? 'Yes' : 'No', + lienReason: this.listedLienReason(party.lienReason), + lienReasonOther: this.listedLienReason(party.lienReason) === 'Other' ? party.lienReason : '', + payDate: this.toDate(party.payDate), + paymentAmount: party.paymentAmount, + currentBalance: party.currentBalance, + openingBalance: party.openingBalance, + isSmsActive: party.isSmsActive, + isEmailActive: party.isEmailActive, + interestCalc: party.interestCalc, + interestDate: this.toDate(party.interestDate), + spDescription: party.spDescription, + freezeCode: party.freezeCode, + advCode: party.advCode, + creditToBranch: party.creditToBranch, + creditToAccountType: party.creditToAccountType, + creditToAccount: party.creditToAccount, + nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No', + nomineeSplitMode: party.nomineeSplitMode + }); + + this.nominees.clear(); + (party.nominees || []).forEach((nominee: any) => { + const group = this.buildNomineeGroup(); + group.patchValue({ + nominationDate: this.toDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo, + birthDate: this.toDate(nominee.birthDate), + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName, + sharePercent: nominee.sharePercent, + sequenceNo: nominee.sequenceNo + }); + this.nominees.push(group); + }); + + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + + // A closed party is a record to look at, not to change. + if (this.isClosed) { + this.partyForm.disable({ emitEvent: false }); + } + } +} diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html new file mode 100644 index 000000000..e07a1271b --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html @@ -0,0 +1,163 @@ + + +
+
+
+
+
+

Accounts Due for Closure

+

Matured DDS accounts still open — pay the customer, then close the account.

+
+
+
+ +
+
+ + @if (!loading && parties.length > 0) { +
+
+
+
+
{{ parties.length | number }}
+
Outstanding
+
+
+
+
+
+
{{ totalPayable | number: '1.2-2' }}
+
Still to Pay
+
+
+ + @if (awaitingCloseCount > 0) { +
+
+
+
{{ awaitingCloseCount | number }}
+
Ready to Close
+
+
+ } +
+ } + +
+ @if (loading) { +

Loading…

+ } @else if (parties.length === 0) { + +
+
+

Nothing to close

+

No DDS account has matured and been left open. Every matured account has been paid out and closed.

+
+ } @else { +
+ + + + + + + + + + + + + @for (party of parties; track party.id) { + + + + + + + + + } + +
PartyCustomerCollected ByMatured OnPayableAction
+
+
{{ party.partyCode }}
+ +
+
{{ party.partyName }} +
+
{{ party.agentName || '—' }}
+
{{ party.agentCode }}
+
+
+
+
{{ party.maturityDate | dateFormat }}
+ +
+ {{ daysOverdue(party) }} {{ daysOverdue(party) === 1 ? 'day' : 'days' }} ago +
+
+
+ @if (awaitingPayout(party)) { + {{ party.currentBalance | number: '1.2-2' }} + } @else { + + Paid out + } + + + @if (heldUnderLien(party)) { + + + + Held under lien + + } @else if (awaitingPayout(party)) { + + } @else { + + } +
+
+ } +
+
diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss new file mode 100644 index 000000000..473e99186 --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss @@ -0,0 +1,97 @@ +/** + * 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/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* An empty list here means the work is done, so the mark is the reassuring colour, not the grey one. */ +.dfc-empty-ic { + color: #15803d; +} + +.dfc-account { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.75rem; +} + +/* + * A maturity the bank has been sitting on for over a month. Called out because the date alone reads + * the same whether it passed yesterday or in the spring, and the two are not the same problem. + */ +.dfc-stale { + color: #b45309; + font-weight: 500; +} + +/* + * An account whose money has already gone out. Words rather than 0.00, so a settled account is not + * read as one that was never collected on - both would show the same figure. + */ +.dfc-paid { + color: #15803d; + font-weight: 600; + font-size: 0.78rem; +} + +/* + * The close action. Green rather than the list's usual blue because it is the step that finishes the + * account, and it only ever appears on a row already paid. + */ +.dfc-close-btn { + color: #15803d; + border-color: rgb(21 128 61 / 30%); + background: rgb(21 128 61 / 6%); + + &:hover { + background: rgb(21 128 61 / 12%); + border-color: rgb(21 128 61 / 45%); + } +} + +/* + * A deposit pledged against a loan. Not a button, because there is nothing for the clerk to do here + * until the loan is cleared — showing one would only lead to a refusal. + */ +.dfc-lien { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid rgb(90 74 156 / 30%); + border-radius: 4px; + background: rgb(90 74 156 / 8%); + color: #5a4a9c; + font-size: 0.78rem; + font-weight: 600; + cursor: help; +} + +/* Plain table rather than mat-table: this list is short by design and needs no sort or paging. */ +.dfc-table { + th, + td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid rgb(0 0 0 / 6%); + } + + th { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 55%); + background: rgb(0 0 0 / 2%); + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } +} diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts new file mode 100644 index 000000000..7792b2bda --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts @@ -0,0 +1,194 @@ +/** + * 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 { MatDialog } from '@angular/material/dialog'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** Custom Components */ +import { PayoutDialogComponent } from '../payout-dialog/payout-dialog.component'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; + +/** + * The DDS accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. + * + * A list of work, not a report. Nothing closes itself: a matured account is money the bank now owes + * the customer, and where it goes has to be agreed with them, so each one waits here until an + * officer deals with it. Before this existed the maturity date simply passed and nobody was told - + * the bank held the money and the customer had to think to ask for it. + * + * Two steps, not one, and an account stays on this list through both: + * + * balance owing pay it out - to another account of theirs, or into matured-deposits payable + * balance nil paid; close it + * + * Splitting them is what a branch actually does. The officer pays, sees the balance fall to nil and + * the money land where it was meant to, and only then closes. One click doing both would close + * accounts whose payment nobody had checked, and a closed account is far harder to put right. + * + * Oldest maturity first, because that is the one the bank has been holding longest. + */ +@Component({ + selector: 'mifosx-due-for-closure', + templateUrl: './due-for-closure.component.html', + styleUrls: ['./due-for-closure.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent + ] +}) +export class DueForClosureComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialog = inject(MatDialog); + private alertService = inject(AlertService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + parties: any[] = []; + loading = true; + + ngOnInit(): void { + this.load(); + } + + private load(): void { + this.loading = true; + this.organizationService.getPartiesDueForClosure().subscribe({ + next: (parties: any) => { + this.parties = Array.isArray(parties) ? parties : parties?.pageItems || []; + this.loading = false; + }, + error: () => { + this.parties = []; + this.loading = false; + } + }); + } + + /** What the bank is holding in total across every matured account still open. */ + get totalPayable(): number { + return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0); + } + + /** + * Whether this one is still waiting on its payment. Anything left on the account is the customer's + * money, so it is paid before the account can be closed. + */ + awaitingPayout(party: any): boolean { + return Number(party.currentBalance || 0) > 0; + } + + /** + * Whether this deposit is standing as security for a loan. + * + * The term has ended and the money is owed, but it cannot leave while it is pledged - so the row + * says so rather than offering a Pay Out the server would refuse. A customer who needed cash before + * their term ended borrowed against the deposit instead of breaking it; the deposit is paid when + * that loan is cleared. + */ + heldUnderLien(party: any): boolean { + return Number(party.amountHeldUnderLien || 0) > 0; + } + + /** How many are already paid and now only need closing - the quicker half of the list. */ + get awaitingCloseCount(): number { + return this.parties.filter((party) => !this.awaitingPayout(party)).length; + } + + /** + * How long the bank has been sitting on this one. Shown because a maturity from three months ago + * is a different problem from one from yesterday, and the list alone would not say so. + */ + daysOverdue(party: any): number { + const matured = this.toDate(party.maturityDate); + if (!matured) { + return 0; + } + return Math.max(0, Math.floor((Date.now() - matured.getTime()) / 86400000)); + } + + /** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */ + private toDate(value: any): Date | null { + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + return value ? new Date(value) : null; + } + + payOut(party: any): void { + const dialogRef = this.dialog.open(PayoutDialogComponent, { + data: party, + width: '520px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((payoutCommand: any) => { + if (!payoutCommand) { + return; + } + this.organizationService.payOutParty(party.id, payoutCommand).subscribe({ + next: () => { + this.alertService.alert({ + type: 'Party Paid Out', + message: payoutCommand.toSavingsAccountNo + ? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo}. Close the account once you have checked it.` + : `${party.partyCode} paid into Matured Deposits Payable, ready to transfer out. Close the account once you have checked it.` + }); + this.load(); + } + // A refusal is shown by the platform's own error handler, which names the reason - repeating + // it here would say the same thing twice. + }); + }); + } + + /** + * Closes an account whose money has already gone out. The server refuses one still carrying a + * balance, so this cannot close an account out from under an unpaid customer. + * + * Asked for first, because closing is the one step here with no way back. + */ + closeAccount(party: any): void { + const confirmation = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: `Close ${party.partyCode}?`, + dialogContext: `${party.partyName}'s account has been paid out in full and can be closed. It cannot be reopened.`, + type: 'warn' + } + }); + + confirmation.afterClosed().subscribe((response: any) => { + if (!response?.confirm) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .closeParty(party.id, { + closeDate: this.dateUtils.formatDate(new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe({ + next: () => { + this.alertService.alert({ type: 'Party Closed', message: `${party.partyCode} is closed.` }); + this.load(); + } + }); + }); + } +} diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html new file mode 100644 index 000000000..d9bf7327f --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html @@ -0,0 +1,73 @@ + + +

+ Nominees + {{ data.partyCode }} · {{ data.partyName }} +

+ + + @if (nominees.length === 0) { +

Nobody is nominated on this account.

+ } @else { +

+ @if (bySequence) { + Paid in the order listed — the first named who can be found receives the deposit. + } @else { + Split by share of the deposit. + } +

+ + + + + + + + + @if (!bySequence) { + + } + + + + @for (nominee of nominees; track nominee.id; let i = $index) { + + + + + + + @if (!bySequence) { + + } + + } + +
@if (bySequence) { Order } @else { # }NameRelationNominatedShare
{{ nominee.sequenceNo ?? i + 1 }} + {{ nominee.name }} + @if (nominee.minor) { + minor · guardian {{ nominee.guardianName || '—' }} + } + {{ nominee.relation }}{{ nominee.nominationDate | dateFormat }} + @if (nominee.sharePercent !== null && nominee.sharePercent !== undefined) { + {{ nominee.sharePercent }}% + } @else { + — + } +
+ + @if (shareWarning) { +

{{ shareWarning }}

+ } + } +
+ + + + diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss new file mode 100644 index 000000000..cfcbc78ea --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss @@ -0,0 +1,84 @@ +/** + * 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/. + */ + +.nd-title { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 0; +} + +.nd-party { + font-size: 13px; + font-weight: 400; + color: rgb(0 0 0 / 55%); +} + +.nd-body { + min-width: 420px; + padding-top: 8px; +} + +.nd-mode { + margin: 0 0 12px; + font-size: 13px; + color: rgb(0 0 0 / 60%); +} + +.nd-empty { + margin: 8px 0; + color: rgb(0 0 0 / 55%); +} + +.nd-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + th { + text-align: left; + font-weight: 500; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 50%); + padding: 0 8px 6px 0; + border-bottom: 1px solid rgb(0 0 0 / 10%); + } + + td { + padding: 8px 8px 8px 0; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: top; + } +} + +/* Shares line up to be read down the column, not across the row. */ +.nd-num { + text-align: right; + font-variant-numeric: tabular-nums; + padding-right: 0; +} + +.nd-seq { + width: 32px; + color: rgb(0 0 0 / 45%); + font-variant-numeric: tabular-nums; +} + +.nd-minor { + display: block; + font-size: 12px; + color: rgb(0 0 0 / 50%); +} + +.nd-warn { + margin: 12px 0 0; + font-size: 13px; + color: #b3261e; +} diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts new file mode 100644 index 000000000..e6e5b4c10 --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts @@ -0,0 +1,82 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Who this account is nominated to, read without opening it for editing. + * + * A clerk asked "who is on this account" has one question, not an intention to change anything, and + * the edit form answers it only after a load, a tab and a scroll - with every field live the whole + * time. This states it and closes. + * + * What is shown follows the split the account was nominated under: a share per nominee under + * Percentage, and the order they are paid in under Sequence, because that ordering is the whole of + * what Sequence records. + */ +@Component({ + selector: 'mifosx-nominee-list-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + DateFormatPipe + ], + templateUrl: './nominee-list-dialog.component.html', + styleUrls: ['./nominee-list-dialog.component.scss'] +}) +export class NomineeListDialogComponent { + /** { partyCode, partyName, splitMode, nominees } */ + data = inject(MAT_DIALOG_DATA); + + get bySequence(): boolean { + return this.data?.splitMode === 'Sequence'; + } + + get nominees(): any[] { + return this.data?.nominees || []; + } + + /** + * What the shares come to. Shown only when it is not the whole deposit - a nomination recorded + * before shares were captured reads as complete otherwise, and the remainder has nobody named + * to receive it. + */ + get shareTotal(): number | null { + if (this.bySequence) { + return null; + } + const stated = this.nominees.filter((n) => n?.sharePercent !== null && n?.sharePercent !== undefined); + if (stated.length === 0) { + return null; + } + return stated.reduce((sum, n) => sum + Number(n.sharePercent || 0), 0); + } + + get shareWarning(): string | null { + if (this.bySequence) { + // Under Sequence the order is the whole of the nomination, so an order nobody stated is the + // same gap a missing share is under Percentage. + const stated = this.nominees.filter((n) => n?.sequenceNo !== null && n?.sequenceNo !== undefined); + return stated.length === this.nominees.length ? null : 'No order was recorded against these nominees.'; + } + const total = this.shareTotal; + if (total === null) { + return 'No shares were recorded against these nominees.'; + } + return total === 100 ? null : `The shares come to ${total}%, not 100%.`; + } +} diff --git a/src/app/deposits/party-master/party-master.component.html b/src/app/deposits/party-master/party-master.component.html index 416b9a6c7..218f2639d 100644 --- a/src/app/deposits/party-master/party-master.component.html +++ b/src/app/deposits/party-master/party-master.component.html @@ -6,538 +6,221 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- -

Party Master Entry

+
+
+
+
+
+

Party Master

+

The DDS accounts on file — each one a customer collected from daily.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'party' : 'parties' }} +
+ +
-
-
- - -
-
-
- -

Identity

-
-
- - Client ID - - - - - Sub Type - - @for (type of subTypes; track type) { - {{ type }} - } - - - - - Agent ID - - - - @if (linkedSavingsAccountId) { - - Savings Account - - - } @else { - - Savings Account No (if any) - - - } - - - Party Code - - - - - Last A/c - - - - - Nominal - - Yes - No - - - - - Party Name - - - - - Party Name 1 - - -
-
- -
-
- -

Address & Contact

-
-
- - Address 1 - - - - - Address 2 - - - - - Address 3 - - - - - City - - - - - Pincode - - - - - Gender - - Male - Female - Other - - - - - Telephone - - - - - Mobile No (SMS) - - -
-
- -
-
- -

KYC

-
-
- - Aadhar No - - - - - PAN Card No - - - - - CKYC ID - - - - - Operation - - @for (op of operations; track op) { - {{ op }} - } - - - - - Birth Date - - - - - - - Occupation - - @for (occ of occupations; track occ) { - {{ occ }} - } - - - - - Risk - - @for (r of riskLevels; track r) { - {{ r }} - } - - -
-
- -
-
- -

Minor / Guardian

-
-
- - Adult / Minor - - @for (opt of adultMinorOptions; track opt) { - {{ opt }} - } - - - - - Minor Date - - - - - - - Guardian Client ID - - - - - Min. A/c Opt - - - - - Relation - - -
-
- -
-
- -

Scheme Terms

-
-
- - Opening Date - - - - - - - Month - - - - - Maturity Date - - - - - Reopen Date - - - - - - - Deposit Amt - - - - - Daily Deposit Amt - - - - - Days - - - - - Ledger No - - - - - Mat. Amt - - - - - Interest Rate - - - - - Withdraw Type - - @for (type of withdrawTypes; track type) { - {{ type }} - } - - -
-
- -
-
- -

Status & Overdue Flags

-
-
-
- OD1 Flag -
-
- OD2 Flag -
- - - Lien - - Yes - No - - - - - Pay Date - - - - - - - Payment Amount - - - - - Current Bal - - - - - Opening Bal - - - -
- Is SMS Active -
-
- Is Email Active -
- - - Int.Calc - - - - - Interest Date - - - - - - - Sp.Description - - - - - Fr'z CD - - @for (code of freezeCodes; track code) { - {{ code }} - } - - - - - Adv Code - - @for (code of advCodes; track code) { - {{ code }} - } - - -
-
- - @if (partyForm.get('advCode').value === 'Transfer To Saving') { -
-
- -

Maturity Payout Routing

-
-
- - Credit To Branch - - - - - Credit To A/C Type - - - - - Credit To A/C - - -
-
- } -
-
- - -
-
-
- - Nominee - - Yes - No - - - - - Percentage - Sequence - - - -
- - @if (nominees.length === 0) { -

No nominees added yet.

- } - - @for (nominee of nominees.controls; track nominee; let i = $index) { -
- - Nom.Dt - - - - - - Name - - - - Mobile No - - - - Birth Dt - - - - - - Minor - - Yes - No - - - - Relation - - - - Gender - - Male - Female - Other - - - - Guar. Name - - - -
- } -
-
-
-
- - @if (isClosed) { -
- - This account is closed. No further collections or edits are possible. -
- } - -
- - - - @if (partyId && !isClosed) { - + + @if (dueForClosureCount > 0) { + + + + {{ dueForClosureCount }} + {{ dueForClosureCount === 1 ? 'account has' : 'accounts have' }} matured and + {{ dueForClosureCount === 1 ? 'is' : 'are' }} still open — + + @if (dueForClosurePayable > 0) { + {{ dueForClosurePayable | number: '1.2-2' }} still owed to customers. + } @else { + paid in full, waiting to be closed. } -
-
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Party Code +
+
+
+
{{ p.partyCode }}
+
{{ p.city || '—' }}
+
+
+
Party Name{{ p.partyName }}Sub Type + {{ p.subTypeDescription || p.subType }} + Collected By +
+
{{ p.agentName || '—' }}
+
{{ p.agentCode }}
+
+
DDS Account + @if (p.savingsAccountNo) { +
+ +
{{ p.savingsAccountStatus }}
+
+ } @else { + + } +
Daily Deposit{{ p.dailyDepositAmount | number: '1.2-2' }}Current Balance + {{ p.currentBalance | number: '1.2-2' }} + Status + + {{ p.status === 'CLOSED' ? 'Closed' : 'Active' }} + + Nominees + @if (nomineeCount(p) > 0) { + + } @else { + None + } + Actions + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No parties match your filter. +
+ } + + +
+ }
diff --git a/src/app/deposits/party-master/party-master.component.scss b/src/app/deposits/party-master/party-master.component.scss index baa31be66..b235f0f44 100644 --- a/src/app/deposits/party-master/party-master.component.scss +++ b/src/app/deposits/party-master/party-master.component.scss @@ -6,159 +6,128 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #0f766e; +@use 'assets/styles/product-list' as pl; -.pm-container { - max-width: 1080px; - margin: 0 auto; - padding: 24px 16px; +@include pl.product-list-styles; + +/* Party-specific amber accent */ +:host { + --pl-accent: #b45309; + --pl-accent-l: #fef3c7; } -.pm-header { +/* A closed party stays listed - its collections and interest are still on the books. */ +.pm-row--closed td:not(.num) { + opacity: 0.55; +} + +.pm-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.pm-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} + +/* The customer's real deposit account number, in the list. */ +.pm-list-account { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.8rem; +} + +/* + A party with no account behind it. Called out rather than left blank: the balance beside it is + money the bank's own books have no record of, which is the thing worth noticing here. +*/ +.pm-list-no-account { + font-size: 0.75rem; + font-style: italic; + color: #9b2c2c; +} + +/* + Matured accounts still open. Deliberately loud: it is money the bank owes a customer who has not + been told, and every day it sits there is a day the bank is holding somebody else's money. Styled + as a link to the work rather than a notice about it - reading it should be the same click as + starting on it. +*/ +.pm-due-banner { display: flex; align-items: center; - gap: 16px; - margin-bottom: 20px; - - &__title { - margin: 0; - font-size: 22px; - font-weight: 500; - } -} - -.pm-back { - color: $primary; -} - -.pm-card { - background: var(--md-sys-color-surface, #fff); - border-radius: 8px; - box-shadow: 0 2px 8px rgb(0 0 0 / 8%); -} - -.pm-tabs { - ::ng-deep .mat-mdc-tab-body-wrapper { - min-height: 300px; - } -} - -.pm-tab-content { - padding: 20px 24px; -} - -.pm-section { - padding: 16px 0; - border-bottom: 1px solid rgb(0 0 0 / 8%); - - &:last-of-type { - border-bottom: none; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 16px; - - i { - color: $primary; - } - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - flex: 1; - } - } -} - -.pm-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px 16px; - align-items: start; -} - -.pm-field { - width: 100%; - - &--wide { - grid-column: span 2; - } -} - -.pm-checkbox-field { - display: flex; - align-items: center; - height: 56px; -} - -.pm-radio-group { - display: flex; - align-items: center; - gap: 16px; - height: 56px; -} - -.pm-add-nominee { - color: $primary; - height: fit-content; - align-self: center; -} - -.pm-empty { - color: rgb(0 0 0 / 50%); - font-size: 14px; - margin: 0 0 8px; -} - -.pm-nominee-row { - display: grid; - grid-template-columns: repeat(8, 1fr) auto; - gap: 8px 12px; - align-items: start; - padding: 10px 0; - border-top: 1px dashed rgb(0 0 0 / 10%); - - &:first-of-type { - border-top: none; - } -} - -.pm-remove-nominee { - color: #b91c1c; - margin-top: 8px; -} - -.pm-action-bar { - display: flex; - justify-content: flex-end; gap: 12px; - padding: 16px 24px; - border-top: 1px solid rgb(0 0 0 / 8%); -} - -.pm-callout { - display: flex; - align-items: center; - gap: 10px; - margin: 0 24px; + margin: 0 0 14px; padding: 12px 16px; - background: rgb(185 28 28 / 8%); - border: 1px solid rgb(185 28 28 / 25%); - border-radius: 6px; - color: #b91c1c; - font-size: 0.9rem; -} + background: rgb(180 83 9 / 8%); + border: 1px solid rgb(180 83 9 / 28%); + border-radius: 8px; + color: #92400e; + text-decoration: none; + transition: background 0.15s ease; -@media (width <= 1000px) { - .pm-grid { - grid-template-columns: repeat(2, 1fr); + &:hover { + background: rgb(180 83 9 / 13%); } - .pm-nominee-row { - grid-template-columns: repeat(2, 1fr); + &__ic { + font-size: 1.05rem; + flex: 0 0 auto; + } + + &__text { + flex: 1 1 auto; + font-size: 0.88rem; + line-height: 1.45; + } + + &__cta { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + white-space: nowrap; } } + +@media (width <= 700px) { + .pm-due-banner { + flex-wrap: wrap; + } +} + +/* + * The nominee count, which opens the list of names rather than the account itself. + * + * Styled as text that can be clicked rather than as a second action button: the row already has one + * action, and a pair of equal-looking buttons would put "look at this" beside "change this" as + * though the two were the same kind of thing. + */ +.pl-nominee-btn { + border: 0; + background: none; + padding: 0; + font: inherit; + color: var(--pl-accent); + font-weight: 500; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + + &:hover { + text-decoration-thickness: 2px; + } +} + +.pl-muted { + color: rgb(0 0 0 / 40%); +} diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts index 125525b16..d6641447a 100644 --- a/src/app/deposits/party-master/party-master.component.ts +++ b/src/app/deposits/party-master/party-master.component.ts @@ -7,42 +7,40 @@ */ /** Angular Imports */ -import { Component, inject } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { MatRadioModule } from '@angular/material/radio'; +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { MatDialog } from '@angular/material/dialog'; + /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; + +/** Custom Components */ +import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component'; /** - * Party Master Entry component. + * Party Master list. * - * The customer's DDS account — linked to an Agent for daily collection, - * carrying its own scheme terms, KYC, two-stage overdue flags, and a - * nominee list. Modelled directly on the legacy Party Master Entry screen - * this replaces. - * - * Backed by its own DDS Party API (`/dds/parties`). The underlying account - * does not yet reuse the Recurring Deposit account engine (see Product - * Setup) — that linkage is a follow-up once the RD-account-specific fields - * this screen doesn't collect (interest/compounding periods, chart - * selection, ...) have a home. Until then the scheme/balance fields here - * are the Party's own. - * - * "Close Account" (command=close) permanently closes the Party once its - * balance is zero - the backend rejects the command otherwise. A closed - * Party can no longer be edited or receive new Party Tran collections. - * - * savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of - * the same client (resolved by account number, since that's what an officer has to hand) - - * clarified with the business (2026-08-24) as "link it if the client already has one, otherwise - * leave it". There is no unlink once linked. + * The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme + * (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit + * shape as Agent Master and the product lists, and shares their stylesheet. */ @Component({ selector: 'mifosx-party-master', @@ -50,478 +48,132 @@ import { Dates } from 'app/core/utils/dates'; styleUrls: ['./party-master.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - MatTabsModule, - MatCheckbox, - MatRadioModule + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip ] }) -export class PartyMasterComponent { - private formBuilder = inject(UntypedFormBuilder); - private router = inject(Router); +export class PartyMasterComponent implements OnInit { private organizationService = inject(OrganizationService); - private settingsService = inject(SettingsService); - private dateUtils = inject(Dates); + private dialog = inject(MatDialog); - partyId: number | null = null; - selectedTabIndex = 0; - partyStatus = 'ACTIVE'; - linkedSavingsAccountId: number | null = null; - - get isClosed(): boolean { - return this.partyStatus === 'CLOSED'; - } - - subTypes = [ - 'Daily Deposit Scheme', - 'Masik Dhan Varsha Deposit' - ]; - operations = [ - 'Self', - 'Guardian', - 'Joint' - ]; - occupations = [ - 'Business', - 'Service', - 'Agriculture', - 'Homemaker', - 'Student', - 'Retired', - 'Other' - ]; - riskLevels = [ - 'Low', - 'Medium', - 'High' - ]; - adultMinorOptions = [ - 'Adult', - 'Minor' - ]; - withdrawTypes = [ - 'Cash', - 'Transfer', - 'Clearing' - ]; - freezeCodes = [ - 'Normal', - 'Debit Freeze', - 'Credit Freeze', - 'Total Freeze' - ]; - advCodes = [ - 'None', - 'Transfer To Saving', - 'Renew' + displayedColumns: string[] = [ + 'partyCode', + 'partyName', + 'subType', + 'agentName', + 'savingsAccountNo', + 'dailyDepositAmount', + 'currentBalance', + 'nominees', + 'status', + 'actions' ]; - partyForm: UntypedFormGroup = this.formBuilder.group({ - clientId: [ - '', - Validators.required - ], - subType: [ - '', - Validators.required - ], - agentId: [ - '', - Validators.required - ], - savingsAccountNo: [''], - lastAccount: [ - { - value: '', - disabled: true - } - ], - partyCode: [ - { - value: '', - disabled: true - } - ], - nominal: [ - 'No', - Validators.required - ], - aadharNo: [''], - partyName: [ - '', - Validators.required - ], - partyName1: [''], - address1: [ - '', - Validators.required - ], - address2: [''], - address3: [''], - city: [ - '', - Validators.required - ], - pincode: [ - '', - Validators.required - ], - gender: [''], - panCardNo: [''], - telephone: [''], - mobileNoSms: [ - '', - Validators.required - ], - operation: [ - 'Self', - Validators.required - ], - birthDate: [''], - occupation: [''], - risk: [''], - ckycId: [''], - adultMinor: [ - 'Adult', - Validators.required - ], - minorDate: [''], - guardianClientId: [''], - minAccountOpt: [''], - relation: [''], - openingDate: [ - '', - Validators.required - ], - month: [''], - maturityDate: [ - { - value: '', - disabled: true - } - ], - reopenDate: [''], - depositAmount: [0], - dailyDepositAmount: [ - 0, - Validators.required - ], - days: [ - '', - Validators.required - ], - ledgerNo: [ - { - value: '', - disabled: true - } - ], - maturityAmount: [ - { - value: 0, - disabled: true - } - ], - interestRate: [ - { - value: 0, - disabled: true - } - ], - withdrawType: [''], - od1Flag: [ - { - value: false, - disabled: true - } - ], - od2Flag: [ - { - value: false, - disabled: true - } - ], - lien: ['No'], - payDate: [''], - paymentAmount: [0], - currentBalance: [ - { - value: 0, - disabled: true - } - ], - openingBalance: [0], - isSmsActive: [true], - isEmailActive: [false], - interestCalc: [ - { - value: 0, - disabled: true - } - ], - interestDate: [''], - spDescription: [''], - freezeCode: ['Normal'], - advCode: ['None'], - creditToBranch: [''], - creditToAccountType: [''], - creditToAccount: [''], - nomineeApplicable: ['No'], - nomineeSplitMode: ['Percentage'], - nominees: this.formBuilder.array([]) - }); - - get nominees(): UntypedFormArray { - return this.partyForm.get('nominees') as UntypedFormArray; - } - - private buildNomineeGroup(): UntypedFormGroup { - return this.formBuilder.group({ - nominationDate: [ - '', - Validators.required - ], - name: [ - '', - Validators.required - ], - mobileNo: [''], - birthDate: [''], - minor: [ - 'No', - Validators.required - ], - relation: [ - '', - Validators.required - ], - gender: [ - '', - Validators.required - ], - guardianName: [''] + /** + * Who a party is nominated to, without opening it for editing. + * + * Asked often enough to belong on the list, and answered from what the list already holds - the + * parties come back with their nominees on them, so this opens rather than fetches. + */ + showNominees(party: any): void { + this.dialog.open(NomineeListDialogComponent, { + data: { + partyCode: party.partyCode, + partyName: party.partyName, + splitMode: party.nomineeSplitMode, + nominees: party.nominees || [] + }, + autoFocus: false }); } - addNominee(): void { - this.nominees.push(this.buildNomineeGroup()); + /** How many people are named on an account, which is what the list column shows. */ + nomineeCount(party: any): number { + return (party?.nominees || []).length; } - removeNominee(index: number): void { - this.nominees.removeAt(index); + /** + * Matured accounts still open, and what they add up to. Shown here because this is the screen an + * officer already opens - money the bank owes a customer should not wait on somebody thinking to + * run a report. + */ + dueForClosureCount = 0; + dueForClosurePayable = 0; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + parties: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.parties.length; } - retrieve(): void { - const clientId = this.partyForm.get('clientId')?.value; - if (!clientId) { - return; - } + get activeCount(): number { + return this.parties.filter((party) => party.status !== 'CLOSED').length; + } + + /** What the whole book is worth today - every open account's running balance added up. */ + get totalCurrentBalance(): number { + return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0); + } + + /** What the agents should be collecting between them on a full collection day. */ + get totalDailyDue(): number { + return this.parties + .filter((party) => party.status !== 'CLOSED') + .reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0); + } + + ngOnInit(): void { this.organizationService.getParties().subscribe((parties: any[]) => { - const match = (parties || []).find((party) => String(party.clientId) === String(clientId)); - if (match) { - this.patchFormFromParty(match); + this.parties = parties || []; + this.dataSource = new MatTableDataSource(this.parties); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.parties.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + + this.loadDueForClosure(); + } + + /** + * Matured accounts still open. A separate call rather than filtered out of the list above, because + * the server decides what counts as matured - comparing dates here would drift from it the first + * time either changed. + */ + private loadDueForClosure(): void { + this.organizationService.getPartiesDueForClosure().subscribe({ + next: (due: any) => { + const rows = Array.isArray(due) ? due : due?.pageItems || []; + this.dueForClosureCount = rows.length; + this.dueForClosurePayable = rows.reduce((sum: number, party: any) => sum + Number(party.currentBalance || 0), 0); + }, + // The party list is the point of this screen; a failed banner must not take it down with it. + error: () => { + this.dueForClosureCount = 0; + this.dueForClosurePayable = 0; } }); } - cancel(): void { - this.partyId = null; - this.partyStatus = 'ACTIVE'; - this.linkedSavingsAccountId = null; - this.partyForm.reset(); - this.partyForm.patchValue({ - nominal: 'No', - operation: 'Self', - adultMinor: 'Adult', - lien: 'No', - isSmsActive: true, - isEmailActive: false, - freezeCode: 'Normal', - advCode: 'None', - nomineeApplicable: 'No', - nomineeSplitMode: 'Percentage', - currentBalance: 0, - maturityAmount: 0, - interestRate: 0 - }); - this.nominees.clear(); - } - - exit(): void { - this.router.navigate(['/accounts/savings']); - } - - save(): void { - const raw = this.partyForm.getRawValue(); - const dateFormat = this.settingsService.dateFormat; - const locale = this.settingsService.language.code; - const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined); - - const payload: any = { - clientId: Number(raw.clientId), - agentId: Number(raw.agentId), - savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined, - subType: raw.subType, - lastAccount: raw.lastAccount || undefined, - nominal: raw.nominal === 'Yes', - aadharNo: raw.aadharNo || undefined, - partyName: raw.partyName, - partyName1: raw.partyName1 || undefined, - address1: raw.address1, - address2: raw.address2 || undefined, - address3: raw.address3 || undefined, - city: raw.city, - pincode: raw.pincode, - gender: raw.gender || undefined, - panCardNo: raw.panCardNo || undefined, - telephone: raw.telephone || undefined, - mobileNoSms: raw.mobileNoSms, - operation: raw.operation, - birthDate: asDate(raw.birthDate), - occupation: raw.occupation || undefined, - risk: raw.risk || undefined, - ckycId: raw.ckycId || undefined, - adultMinor: raw.adultMinor, - minorDate: asDate(raw.minorDate), - guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined, - minAccountOpt: raw.minAccountOpt || undefined, - relation: raw.relation || undefined, - openingDate: asDate(raw.openingDate), - month: raw.month || undefined, - reopenDate: asDate(raw.reopenDate), - depositAmount: raw.depositAmount || undefined, - dailyDepositAmount: raw.dailyDepositAmount, - days: raw.days, - withdrawType: raw.withdrawType || undefined, - lien: raw.lien === 'Yes', - payDate: asDate(raw.payDate), - paymentAmount: raw.paymentAmount || undefined, - openingBalance: raw.openingBalance || 0, - isSmsActive: !!raw.isSmsActive, - isEmailActive: !!raw.isEmailActive, - interestDate: asDate(raw.interestDate), - spDescription: raw.spDescription || undefined, - freezeCode: raw.freezeCode || undefined, - advCode: raw.advCode || undefined, - creditToBranch: raw.creditToBranch || undefined, - creditToAccountType: raw.creditToAccountType || undefined, - creditToAccount: raw.creditToAccount || undefined, - nomineeApplicable: raw.nomineeApplicable === 'Yes', - nomineeSplitMode: raw.nomineeSplitMode || undefined, - dateFormat, - locale, - nominees: (raw.nominees || []).map((nominee: any) => ({ - nominationDate: this.dateUtils.getDate(nominee.nominationDate), - name: nominee.name, - mobileNo: nominee.mobileNo || undefined, - birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined, - minor: nominee.minor === 'Yes', - relation: nominee.relation, - gender: nominee.gender, - guardianName: nominee.guardianName || undefined - })) - }; - - if (this.partyId) { - this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit()); - } else { - this.organizationService.createParty(payload).subscribe(() => this.exit()); - } - } - - closeAccount(): void { - if (!this.partyId) { - return; - } - const dateFormat = this.settingsService.dateFormat; - const locale = this.settingsService.language.code; - this.organizationService - .closeParty(this.partyId, { - closeDate: this.dateUtils.formatDate(new Date(), dateFormat), - dateFormat, - locale - }) - .subscribe(() => this.exit()); - } - - private patchFormFromParty(party: any): void { - this.partyId = party.id; - this.partyStatus = party.status || 'ACTIVE'; - this.linkedSavingsAccountId = party.savingsAccountId || null; - this.partyForm.patchValue({ - clientId: party.clientId, - subType: party.subType, - agentId: party.agentId, - partyCode: party.partyCode, - lastAccount: party.lastAccount, - nominal: party.nominal ? 'Yes' : 'No', - aadharNo: party.aadharNo, - partyName: party.partyName, - partyName1: party.partyName1, - address1: party.address1, - address2: party.address2, - address3: party.address3, - city: party.city, - pincode: party.pincode, - gender: party.gender, - panCardNo: party.panCardNo, - telephone: party.telephone, - mobileNoSms: party.mobileNoSms, - operation: party.operation, - birthDate: party.birthDate ? new Date(party.birthDate) : null, - occupation: party.occupation, - risk: party.risk, - ckycId: party.ckycId, - adultMinor: party.adultMinor, - minorDate: party.minorDate ? new Date(party.minorDate) : null, - guardianClientId: party.guardianClientId, - minAccountOpt: party.minAccountOpt, - relation: party.relation, - openingDate: party.openingDate ? new Date(party.openingDate) : null, - month: party.month, - maturityDate: party.maturityDate, - reopenDate: party.reopenDate ? new Date(party.reopenDate) : null, - depositAmount: party.depositAmount, - dailyDepositAmount: party.dailyDepositAmount, - days: party.days, - ledgerNo: party.ledgerNo, - maturityAmount: party.maturityAmount, - interestRate: party.interestRate, - withdrawType: party.withdrawType, - od1Flag: party.od1Flag, - od2Flag: party.od2Flag, - lien: party.lien ? 'Yes' : 'No', - payDate: party.payDate ? new Date(party.payDate) : null, - paymentAmount: party.paymentAmount, - currentBalance: party.currentBalance, - openingBalance: party.openingBalance, - isSmsActive: party.isSmsActive, - isEmailActive: party.isEmailActive, - interestCalc: party.interestCalc, - interestDate: party.interestDate ? new Date(party.interestDate) : null, - spDescription: party.spDescription, - freezeCode: party.freezeCode, - advCode: party.advCode, - creditToBranch: party.creditToBranch, - creditToAccountType: party.creditToAccountType, - creditToAccount: party.creditToAccount, - nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No', - nomineeSplitMode: party.nomineeSplitMode - }); - - this.nominees.clear(); - (party.nominees || []).forEach((nominee: any) => { - const group = this.buildNomineeGroup(); - group.patchValue({ - nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null, - name: nominee.name, - mobileNo: nominee.mobileNo, - birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null, - minor: nominee.minor ? 'Yes' : 'No', - relation: nominee.relation, - gender: nominee.gender, - guardianName: nominee.guardianName - }); - this.nominees.push(group); - }); + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); } } diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html new file mode 100644 index 000000000..4e724598e --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html @@ -0,0 +1,79 @@ + + +

Pay out {{ data.partyCode }}

+ + + +
+
+ Customer + {{ data.partyName }} +
+
+ Matured on + {{ data.maturityDate | dateFormat }} +
+
+ Amount payable + {{ data.currentBalance | number: '1.2-2' }} +
+
+ + @if (loading) { + + } @else { +

Where should this go?

+ + + + Into another account of theirs here + @if (ownAccounts.length === 0) { + {{ data.partyName }} has no other savings account at this bank + } @else { + Moved across today + } + + + @if (destination === 'own' && ownAccounts.length > 0) { + + } + + + To an account at another bank + + Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers + + + + + @if (destination === 'other') { + +
+ The money comes off {{ data.partyCode }} today and sits in the bank's Matured Deposits Payable + account until the transfer is made from Fund Transfers — so it is accounted for the whole + time, and a failed transfer loses nothing. +
+ } + } +
+ + + + + diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss new file mode 100644 index 000000000..7d58ea20a --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss @@ -0,0 +1,92 @@ +/** + * 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/. + */ + +/* What is being paid and to whom. Read against the customer standing there, so it comes first. */ +.po-summary { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + margin-bottom: 18px; + background: rgb(0 0 0 / 3%); + border-radius: 6px; + + &__row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 0.85rem; + + /* The figure the officer is about to hand over - given the weight that deserves. */ + &--amount { + padding-top: 6px; + border-top: 1px solid rgb(0 0 0 / 8%); + font-size: 1rem; + } + } + + &__label { + color: rgb(0 0 0 / 55%); + } + + &__value { + font-weight: 500; + font-variant-numeric: tabular-nums; + } +} + +.po-question { + margin: 0 0 10px; + font-size: 0.9rem; + font-weight: 500; +} + +.po-choices { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* + * Each choice carries its own consequence underneath it. Two radio labels alone would not tell an + * officer that one keeps the money inside the bank and the other starts it on its way out. + */ +.po-choice { + display: block; + padding: 4px 0; + + &__title { + display: block; + font-size: 0.88rem; + } + + &__note { + display: block; + font-size: 0.75rem; + color: rgb(0 0 0 / 55%); + line-height: 1.4; + } +} + +.po-account { + width: 100%; + margin: 8px 0 4px 32px; + max-width: calc(100% - 32px); +} + +/* What will actually happen, in the words the customer will ask about. */ +.po-callout { + margin-top: 12px; + padding: 10px 12px; + background: rgb(180 83 9 / 8%); + border: 1px solid rgb(180 83 9 / 22%); + border-radius: 6px; + color: #92400e; + font-size: 0.78rem; + line-height: 1.5; +} diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts new file mode 100644 index 000000000..120900150 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts @@ -0,0 +1,109 @@ +/** + * 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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Where a matured DDS account's money should go. + * + * Two destinations, and the difference is whether the money leaves the bank. Another account of the + * customer's here is a move across the books. Anywhere else means a transfer out, which clears later + * or fails - so the money is parked in the matured-deposits payable account first and the branch + * sends it on from there, rather than being launched into a gap where it has left one account and + * arrived at none. + * + * The amount is not asked for. It is whatever the account holds, worked out by the server: a figure + * typed here could pay out more than the customer has, or quietly leave some behind. + */ +@Component({ + selector: 'mifosx-payout-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatRadioModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + DateFormatPipe + ], + templateUrl: './payout-dialog.component.html', + styleUrls: ['./payout-dialog.component.scss'] +}) +export class PayoutDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + /** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */ + destination: 'own' | 'other' = 'own'; + + /** + * The customer's accounts this maturity can be paid into. + * + * Worked out by the server, which applies the same conditions the payout enforces: an active + * savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme + * accounts. Filtering here instead let the two drift - the list once offered a scheme account + * belonging to a party that had already closed, which would have stranded the money. + */ + ownAccounts: any[] = []; + selectedAccountNo = ''; + loading = true; + + ngOnInit(): void { + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ + next: (accounts: any) => { + this.ownAccounts = accounts || []; + + // Nothing here to pay into, so the only real choice is a transfer out - offered as the + // default rather than leaving the officer on an option that cannot be completed. + if (this.ownAccounts.length === 0) { + this.destination = 'other'; + } else if (this.ownAccounts.length === 1) { + this.selectedAccountNo = this.ownAccounts[0].accountNo; + } + this.loading = false; + }, + error: () => { + this.ownAccounts = []; + this.destination = 'other'; + this.loading = false; + } + }); + } + + get canPayOut(): boolean { + if (this.loading) { + return false; + } + return this.destination === 'other' || !!this.selectedAccountNo; + } + + payOut(): void { + this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {}); + } +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html new file mode 100644 index 000000000..fea269d3b --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html @@ -0,0 +1,118 @@ + + +

Close {{ data.partyCode }} early

+ + + @if (loading) { + + } + + +
+
+ Customer + {{ data.partyName }} +
+
+ Balance + {{ data.currentBalance | number: '1.2-2' }} +
+
+ Matures on + {{ data.maturityDate | dateFormat }} +
+
+ Scheme rate + {{ data.interestRate | number: '1.0-2' }}% +
+
+ +

Who bears the cost of closing early?

+ + + + + + + + + @if (scheme && scheme.prematureAgentCommissionPenalty > 0) { +

+ Either way, {{ data.agentCode }} is charged + {{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% + of the commission owed to them — the round was not kept going. +

+ } + +

Where should the balance go?

+ + + + Into another account of theirs at this bank + + Park it for transfer to another bank + + + @if (destination === 'own' && ownAccounts.length > 0) { + + Credit to account + + @for (account of ownAccounts; track account.accountNo) { + {{ account.accountNo }} — {{ account.productName }} + } + + + } + + @if (ownAccounts.length === 0 && !loading) { +

+ This customer has no other account here, so the balance is parked in matured-deposits payable for the + branch to send on. +

+ } + +

This closes the account. It cannot be reopened.

+
+ + + + + diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss new file mode 100644 index 000000000..a20cc2ccb --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss @@ -0,0 +1,112 @@ +/** + * 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/. + */ + +/* The account as it stands, so the choice below is made against figures rather than from memory. */ +.pcd-summary { + margin: 4px 0 20px; + padding: 12px 14px; + border-radius: 6px; + background: rgb(0 0 0 / 3%); +} + +.pcd-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 3px 0; + font-size: 0.85rem; + + span { + color: rgb(0 0 0 / 60%); + } +} + +.pcd-amount { + font-variant-numeric: tabular-nums; +} + +.pcd-question { + margin: 20px 0 10px; + font-size: 0.9rem; + font-weight: 600; +} + +.pcd-choices { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* + * Each answer is a card rather than a bare radio, because the two are not interchangeable - they take + * money from different people, and the consequence has to be readable before the choice is made. + */ +.pcd-choice { + display: flex; + align-items: flex-start; + gap: 4px; + padding: 10px 12px 12px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 6px; + cursor: pointer; + + &:hover { + background: rgb(0 0 0 / 2%); + } +} + +.pcd-choice--on { + border-color: #b45309; + background: rgb(180 83 9 / 5%); +} + +.pcd-choice-body { + padding-top: 2px; +} + +.pcd-choice-title { + font-weight: 600; + font-size: 0.88rem; +} + +.pcd-choice-detail { + margin-top: 3px; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 65%); +} + +.pcd-destinations { + display: flex; + flex-direction: column; + gap: 6px; +} + +.pcd-field { + width: 100%; + margin-top: 12px; +} + +.pcd-note { + margin: 12px 0 0; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 65%); +} + +/* The one step here with no way back, so it is said plainly rather than left to the button colour. */ +.pcd-warn { + margin: 18px 0 0; + padding: 9px 12px; + border-radius: 6px; + border: 1px solid rgb(185 28 28 / 25%); + background: rgb(185 28 28 / 6%); + color: #b91c1c; + font-size: 0.8rem; + font-weight: 500; +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts new file mode 100644 index 000000000..0cc913c5d --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts @@ -0,0 +1,136 @@ +/** + * 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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { ProductsService } from 'app/products/products.service'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Closing a DDS account before its term is up. + * + * A customer who stops saving early costs the bank the interest it had promised on money it no longer + * has for as long as it expected. Somebody has to carry that, and the bank allows two answers - so + * this asks which, and it is the only thing it asks. + * + * What each answer costs is the scheme's, not the clerk's: the reduced rate and the agent's share are + * set once when the scheme is created and shown here as fact. A figure typed at the counter is a + * figure that can be typed differently for the next customer, which is exactly what a published term + * is meant to prevent. + * + * The destination works as it does on a maturity payout - another account of theirs here, or parked + * in matured-deposits payable for a transfer out - because what is left over is still their money and + * has to reach them the same way. + */ +@Component({ + selector: 'mifosx-premature-close-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatRadioModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + DateFormatPipe + ], + templateUrl: './premature-close-dialog.component.html', + styleUrls: ['./premature-close-dialog.component.scss'] +}) +export class PrematureCloseDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private productsService = inject(ProductsService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + /** Who carries the shortfall. No default - the two answers take money from different people. */ + borneBy: 'CUSTOMER' | 'AGENT' | '' = ''; + + /** 'own' moves what is left to another account here; 'other' parks it for a transfer out. */ + destination: 'own' | 'other' = 'own'; + + ownAccounts: any[] = []; + selectedAccountNo = ''; + loading = true; + + /** + * The scheme's terms, shown so the clerk can tell the customer what each answer costs before + * choosing. Null while loading, and left null if the scheme cannot be read - the choice is then + * still offered, because the server applies the terms either way and a screen that could not + * display them should not block a closure the customer has asked for. + */ + scheme: any = null; + + ngOnInit(): void { + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ + next: (accounts: any) => { + this.ownAccounts = accounts || []; + if (this.ownAccounts.length === 0) { + this.destination = 'other'; + } else if (this.ownAccounts.length === 1) { + this.selectedAccountNo = this.ownAccounts[0].accountNo; + } + this.loading = false; + }, + error: () => { + this.ownAccounts = []; + this.destination = 'other'; + this.loading = false; + } + }); + + // The terms are read from the scheme the account runs on, so what is shown here is what the + // server will actually charge rather than a second copy that can drift from it. Matched on the + // code the party carries, which is the only handle it has on its scheme. + this.productsService.getDdsAccountTypes().subscribe({ + next: (schemes: any) => { + const all = Array.isArray(schemes) ? schemes : schemes?.pageItems || []; + this.scheme = all.find((s: any) => s.accountTypeCode === this.data.subType) || null; + }, + error: () => (this.scheme = null) + }); + } + + /** The rate the customer would be left on if they carry it. Never below nothing. */ + get reducedRate(): number { + const rate = Number(this.data.interestRate || 0); + const reduction = Number(this.scheme?.prematureInterestReduction || 0); + return Math.max(0, rate - reduction); + } + + get canClose(): boolean { + if (this.loading || !this.borneBy) { + return false; + } + return this.destination === 'other' || !!this.selectedAccountNo; + } + + close(): void { + this.dialogRef.close({ + shortfallBorneBy: this.borneBy, + ...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}) + }); + } +} diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 9b64b94da..81b0494d4 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common'; import { HasPermissionDirective } from './has-permission/has-permission.directive'; import { FormatAmountDirective } from './format-amount.directive'; import { ValidateOnFocusDirective } from './validate-on-focus.directive'; +import { RestrictInputDirective } from './restrict-input.directive'; /** * Directives Module @@ -25,12 +26,14 @@ import { ValidateOnFocusDirective } from './validate-on-focus.directive'; CommonModule, HasPermissionDirective, FormatAmountDirective, - ValidateOnFocusDirective + ValidateOnFocusDirective, + RestrictInputDirective ], exports: [ HasPermissionDirective, FormatAmountDirective, - ValidateOnFocusDirective + ValidateOnFocusDirective, + RestrictInputDirective ] }) export class DirectivesModule {} diff --git a/src/app/directives/restrict-input.directive.ts b/src/app/directives/restrict-input.directive.ts new file mode 100644 index 000000000..176b32cbc --- /dev/null +++ b/src/app/directives/restrict-input.directive.ts @@ -0,0 +1,137 @@ +/** + * 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 { Directive, HostListener, Input, ElementRef, inject } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +/** The kinds of field this knows how to hold to a shape. */ +export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar'; + +interface Rule { + /** Characters this kind will accept at all. */ + allowed: RegExp; + /** How many of them the field can hold, so it cannot be overfilled. */ + maxLength: number; + /** Whether what is typed is forced to upper case as it is entered. */ + upper?: boolean; +} + +const RULES: Record = { + digits: { allowed: /[0-9]/, maxLength: 20 }, + phone: { allowed: /[0-9+()\-\s]/, maxLength: 20 }, + // A PAN is five letters, four digits, one letter - and is always written in capitals. + pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }, + // Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits. + aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true } +}; + +/** + * Stops a field being filled with something it could never hold. + * + * A mobile number typed as letters, or a PAN of fifteen characters, is not a mistake worth + * discovering on save - the customer is at the counter now, and the clerk is looking at the field + * they just typed. Refusing the keystroke says so at the moment it happens, with nothing to read + * and nothing to undo. + * + * Pasting is handled too, and is where this earns its place: a number copied from a spreadsheet + * arrives with spaces, dashes or a +91 in front, and pasting is the one way into a field that + * bypasses every keystroke rule. What can be salvaged is kept, the rest dropped. + * + * This only refuses characters the field cannot contain. Whether what is left is a valid number is + * a separate question, answered by the form's own validators and again by the server - a screen + * restriction is a convenience, not a guarantee. + */ +@Directive({ + selector: '[mifosxRestrictInput]', + standalone: true +}) +export class RestrictInputDirective { + private el = inject(ElementRef); + private control = inject(NgControl, { optional: true }); + + @Input('mifosxRestrictInput') kind: RestrictKind = 'digits'; + + private get rule(): Rule { + return RULES[this.kind] ?? RULES.digits; + } + + /** + * Keys that move around or edit rather than enter a character, plus the usual copy/paste and + * select-all chords. Blocking these would leave the clerk unable to correct their own typing. + */ + private isEditingKey(event: KeyboardEvent): boolean { + const navigation = [ + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'Enter', + 'Home', + 'End', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown' + ]; + return navigation.includes(event.key) || event.ctrlKey || event.metaKey; + } + + @HostListener('keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + if (this.isEditingKey(event) || event.key.length > 1) { + return; + } + const input = this.el.nativeElement as HTMLInputElement; + const hasSelection = input.selectionStart !== input.selectionEnd; + + if (!this.rule.allowed.test(event.key)) { + event.preventDefault(); + return; + } + // Replacing a selection does not make the field any longer, so only a plain insert can overfill. + if (!hasSelection && input.value.length >= this.rule.maxLength) { + event.preventDefault(); + } + } + + @HostListener('paste', ['$event']) + onPaste(event: ClipboardEvent): void { + event.preventDefault(); + const pasted = event.clipboardData?.getData('text') ?? ''; + const kept = Array.from(pasted) + .filter((ch) => this.rule.allowed.test(ch)) + .join('') + .slice(0, this.rule.maxLength); + this.write(this.rule.upper ? kept.toUpperCase() : kept); + } + + /** Upper case is applied as the field is left, so the caret does not jump while typing. */ + @HostListener('blur') + onBlur(): void { + if (!this.rule.upper) { + return; + } + const current = String(this.el.nativeElement.value ?? ''); + if (current !== current.toUpperCase()) { + this.write(current.toUpperCase()); + } + } + + /** + * Writes through the form control where there is one, so the value the form holds and the value + * on screen stay the same thing - setting only the element would leave the form with the old one. + */ + private write(value: string): void { + this.el.nativeElement.value = value; + if (this.control?.control) { + this.control.control.setValue(value); + } else { + this.el.nativeElement.dispatchEvent(new Event('input')); + } + } +} diff --git a/src/app/organization/agent-master/agent-master.component.html b/src/app/organization/agent-master/agent-master.component.html index 243121fd8..f5733e41e 100644 --- a/src/app/organization/agent-master/agent-master.component.html +++ b/src/app/organization/agent-master/agent-master.component.html @@ -6,350 +6,175 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- -

Agent Master Entry

+
+
+
+
+
+

Agent Master

+

The DDS collection agents on file.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'agent' : 'agents' }} +
+ +
-
-
-
-
- -

Agent Details

-
-
- - Client ID - - - - - Agent Code - - - - - Agent Name ID - - - - - Agent Address (Line 1) - - - - - Agent Address (Line 2) - - - - - Agent City - - - - - Agent PinCode - - - - - Agent Telephone - - - - - Home Branch - - -
+
+
+
+
+
{{ totalCount | number }}
+
Total Agents
- -
-
- -

Collection & Commission Settings

-
-
- - Com. Calculation - - Yes - No - - - - - TDS - - Yes - No - - - - - TDS % - - - - - Cur.TDS - - - - - TDS Amt - - - - - Limit - - - - - Machine Rent - - -
+
+
+
+
+
{{ activeCount | number }}
+
Active Agents
- -
-
- -

Settlement & Security Accounts

-
-
- - Saving Branch - - - - - Agent Saving A/C Type - - @for (accountType of agentSavingAccountTypes; track accountType) { - {{ accountType }} - } - - - - - Agent Saving A/C No - - - - - Trf Branch - - - - - Trf.Amt A/C Type - - - - - Trf.Amt A/C No - - - - - Agent Security Branch - - - - - Agent Security A/C Type - - @for (accountType of agentSavingAccountTypes; track accountType) { - {{ accountType }} - } - - - - - Agent Security A/C No - - - - - Agent Opening Date - - - - - - - Agent Birth Date - - - - - - - Agent Current Balance - - - - - Agent Opening Balance - - -
+
+
+
+
+
{{ totalCurrentBalance | number: '1.2-2' }}
+
Cash With Agents (Unsettled)
+
+
-
-
- -

Collection Limit History

- -
- - @if (limits.length === 0) { -

No limit periods added yet.

- } - - @for (limitRow of limits.controls; track limitRow; let i = $index) { -
- - From Date - - - - - - To Date - - - - - - Limit - - - -
- } +
+
+ +
-
-
- -

Guarantor's Detail

- -
- - @if (guarantors.length === 0) { -

No guarantors added yet.

- } - - @for (guarantor of guarantors.controls; track guarantor; let i = $index) { -
- - Client ID - - - - Guarantor Name - - - - Address 1 - - - - Address 2 - - - - City - - - - Pin Code - - - - Telephone - - - -
- } -
- -
-
- -

Deductions & Deposits

-
-
- - Comm - - - - - Other Comm - - - - - Ag.Deposit - - - - - Adv.Festival - - - - - Other Deduction - - -
-
- -
- - - -
- + } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Agent Code +
+
+
+
{{ a.agentCode }}
+
+
+
Agent Name{{ a.agentNameId }}Branch + @if (a.homeBranch) { + {{ a.homeBranch }} + } @else { + Not set + } + City{{ a.agentCity }}Saving A/C No + {{ a.agentSavingAccountNo }} + Current Balance + {{ a.agentCurrentBalance | number: '1.2-2' }} + Status + + {{ a.active ? 'Active' : 'Inactive' }} + + Created + {{ a.createdDate ? (a.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + Actions + + @if (a.closeDate) { + Closed {{ a.closeDate | dateFormat }} + } @else { + + } +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No agents match your filter. +
+ } + + +
+ }
diff --git a/src/app/organization/agent-master/agent-master.component.scss b/src/app/organization/agent-master/agent-master.component.scss index 1532696bc..4f6cd4cfe 100644 --- a/src/app/organization/agent-master/agent-master.component.scss +++ b/src/app/organization/agent-master/agent-master.component.scss @@ -6,136 +6,54 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #0f766e; +@use 'assets/styles/product-list' as pl; -.am-container { - max-width: 1040px; - margin: 0 auto; - padding: 24px 16px; +@include pl.product-list-styles; + +/* Agent-specific indigo accent */ +:host { + --pl-accent: #4338ca; + --pl-accent-l: #e0e7ff; } -.am-header { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 20px; - - &__title { - margin: 0; - font-size: 22px; - font-weight: 500; - } +/* A retired agent stays listed but reads as switched off. */ +.am-row--inactive td:not(.num) { + opacity: 0.55; } -.am-back { - color: $primary; +/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */ +.pl-action-btn--close { + color: #9b2c2c; } -.am-card { - background: var(--md-sys-color-surface, #fff); - border-radius: 8px; - box-shadow: 0 2px 8px rgb(0 0 0 / 8%); - padding: 8px 24px 24px; +/* A closed agent shows when, in place of the actions it no longer has. */ +.am-closed-on { + font-size: 12px; + font-style: italic; + color: #9b2c2c; } -.am-section { - padding: 20px 0; - border-bottom: 1px solid rgb(0 0 0 / 8%); - - &:last-of-type { - border-bottom: none; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 16px; - - i { - color: $primary; - } - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - flex: 1; - } - } +.am-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; } -.am-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px 16px; +.am-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); } -.am-field { - width: 100%; - - &--wide { - grid-column: span 2; - } -} - -.am-add-guarantor { - color: $primary; -} - -.am-empty-guarantors { - color: rgb(0 0 0 / 50%); - font-size: 14px; - margin: 0 0 8px; -} - -.am-guarantor-row { - display: grid; - grid-template-columns: repeat(7, 1fr) auto; - gap: 8px 12px; - align-items: start; - padding: 10px 0; - border-top: 1px dashed rgb(0 0 0 / 10%); - - &:first-of-type { - border-top: none; - } -} - -.am-limit-row { - display: grid; - grid-template-columns: repeat(3, 1fr) auto; - gap: 8px 12px; - align-items: start; - padding: 10px 0; - border-top: 1px dashed rgb(0 0 0 / 10%); - - &:first-of-type { - border-top: none; - } -} - -.am-remove-guarantor { - color: #b91c1c; - margin-top: 8px; -} - -.am-action-bar { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 20px; - padding-top: 16px; - flex-wrap: wrap; -} - -@media (width <= 900px) { - .am-grid { - grid-template-columns: repeat(2, 1fr); - } - - .am-guarantor-row, - .am-limit-row { - grid-template-columns: repeat(2, 1fr); - } +/* An agent with no branch is an agent no branch's batch run will collect for - say so plainly + rather than showing an em dash that reads like "nothing to see here". */ +.am-no-branch { + font-size: 12px; + font-style: italic; + color: #9b2c2c; } diff --git a/src/app/organization/agent-master/agent-master.component.ts b/src/app/organization/agent-master/agent-master.component.ts index 4b35e05f4..aa6572d10 100644 --- a/src/app/organization/agent-master/agent-master.component.ts +++ b/src/app/organization/agent-master/agent-master.component.ts @@ -7,375 +7,122 @@ */ /** Angular Imports */ -import { Component, inject } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; /** - * Agent Master Entry component. + * Agent Master list. * - * A DDS collection agent — either a person or an institution — registered - * with its own settlement savings account and one or more guarantors. As of - * 2026-08-24 the Agent also does the daily collection work directly (the - * SubAgent tier was removed): it carries the collection-limit history, - * TDS/commission settings and security account SubAgent used to own. - * - * Backed by its own DDS Agent API (`/dds/agents`) - independent of Staff/ - * Client CRUD, per the DDS agent/commission hierarchy being genuinely new - * feature work. + * The DDS collection agents on file. Follows the same list -> create/edit shape as the product lists + * and shares their stylesheet. */ @Component({ selector: 'mifosx-agent-master', templateUrl: './agent-master.component.html', styleUrls: ['./agent-master.component.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] }) -export class AgentMasterComponent { - private formBuilder = inject(UntypedFormBuilder); - private router = inject(Router); +export class AgentMasterComponent implements OnInit { private organizationService = inject(OrganizationService); - private settingsService = inject(SettingsService); - private dateUtils = inject(Dates); - maxDate = new Date(); - agentId: number | null = null; - - agentSavingAccountTypes = [ - 'Savings Deposit Individual A/C', - 'Savings Deposit Joint A/C', - 'Current Account' + displayedColumns: string[] = [ + 'agentCode', + 'clientName', + 'homeBranch', + 'agentCity', + 'agentSavingAccountNo', + 'agentCurrentBalance', + 'status', + 'createdDate', + 'actions' ]; - agentForm: UntypedFormGroup = this.formBuilder.group({ - clientId: [ - '', - Validators.required - ], - agentCode: [ - { - value: '', - disabled: true - } - ], - agentNameId: [ - '', - Validators.required - ], - agentAddress1: [ - '', - Validators.required - ], - agentAddress2: [''], - agentCity: [ - '', - Validators.required - ], - agentPinCode: [ - '', - Validators.required - ], - agentTelephone: [''], - agentSavingAccountType: [ - '', - Validators.required - ], - agentSavingAccountNo: [ - '', - Validators.required - ], - agentOpeningDate: [ - '', - Validators.required - ], - agentBirthDate: [ - '', - Validators.required - ], - agentCurrentBalance: [ - { - value: 0, - disabled: true - } - ], - agentOpeningBalance: [ - { - value: 0, - disabled: true - } - ], - homeBranch: [ - '', - Validators.required - ], - tdsApplicable: [ - 'No', - Validators.required - ], - tdsPercent: [0], - currentTds: [ - { - value: 0, - disabled: true - } - ], - tdsAmount: [ - { - value: 0, - disabled: true - } - ], - savingBranch: [ - '', - Validators.required - ], - transferBranch: [''], - transferAmountAccountType: [''], - transferAccountNo: [''], - commissionCalculation: [ - 'Yes', - Validators.required - ], - limit: [0], - machineRent: [0], - agentSecurityBranch: [''], - agentSecurityAccountType: [''], - agentSecurityAccountNo: [''], - commission: [ - { - value: 0, - disabled: true - } - ], - otherCommission: [0], - agentDeposit: [0], - festivalAdvance: [0], - otherDeduction: [0], - limits: this.formBuilder.array([]), - guarantors: this.formBuilder.array([]) + dataSource: MatTableDataSource = new MatTableDataSource([]); + agents: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.agents.length; + } + + get activeCount(): number { + return this.agents.filter((agent) => agent.active).length; + } + + /** + * Cash the agents are collectively holding but have not settled to a branch yet — an agent's + * balance rises on every party collection and falls when it hands the money over. Inactive agents + * count too: money still out with a retired agent is exactly what this figure is for. + */ + get totalCurrentBalance(): number { + return this.agents.reduce((sum, agent) => sum + Number(agent.agentCurrentBalance || 0), 0); + } + + ngOnInit(): void { + this.loadAgents(); + } + + private loadAgents(): void { + this.organizationService.getAgents().subscribe((agents: any[]) => { + this.agents = agents || []; + this.dataSource = new MatTableDataSource(this.agents); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.agents.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 }); - get limits(): UntypedFormArray { - return this.agentForm.get('limits') as UntypedFormArray; - } - - get guarantors(): UntypedFormArray { - return this.agentForm.get('guarantors') as UntypedFormArray; - } - - private buildLimitGroup(): UntypedFormGroup { - return this.formBuilder.group({ - fromDate: [ - '', - Validators.required - ], - toDate: [ - '', - Validators.required - ], - limit: [ - 0, - Validators.required - ] - }); - } - - private buildGuarantorGroup(): UntypedFormGroup { - return this.formBuilder.group({ - clientId: [ - '', - Validators.required - ], - guarantorName: [ - '', - Validators.required - ], - address1: [ - '', - Validators.required - ], - address2: [''], - city: [ - '', - Validators.required - ], - pinCode: [ - '', - Validators.required - ], - telephone: [''] - }); - } - - addLimit(): void { - this.limits.push(this.buildLimitGroup()); - } - - removeLimit(index: number): void { - this.limits.removeAt(index); - } - - addGuarantor(): void { - this.guarantors.push(this.buildGuarantorGroup()); - } - - removeGuarantor(index: number): void { - this.guarantors.removeAt(index); - } - - retrieve(): void { - const clientId = this.agentForm.get('clientId')?.value; - if (!clientId) { - return; - } - this.organizationService.getAgents().subscribe((agents: any[]) => { - const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId)); - if (match) { - this.patchFormFromAgent(match); - } - }); - } - - cancel(): void { - this.agentId = null; - this.agentForm.reset(); - this.agentForm.patchValue({ - agentCurrentBalance: 0, - agentOpeningBalance: 0, - tdsApplicable: 'No', - commissionCalculation: 'Yes', - currentTds: 0, - tdsAmount: 0, - commission: 0 - }); - this.limits.clear(); - this.guarantors.clear(); - } - - exit(): void { - this.router.navigate(['/organization']); - } - - save(): void { - const raw = this.agentForm.getRawValue(); - const dateFormat = this.settingsService.dateFormat; - const locale = this.settingsService.language.code; - - const payload: any = { - clientId: Number(raw.clientId), - agentNameId: raw.agentNameId, - agentAddress1: raw.agentAddress1, - agentAddress2: raw.agentAddress2 || undefined, - agentCity: raw.agentCity, - agentPinCode: raw.agentPinCode, - agentTelephone: raw.agentTelephone || undefined, - agentSavingAccountType: raw.agentSavingAccountType, - agentSavingAccountNo: raw.agentSavingAccountNo, - agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat), - agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat), - agentOpeningBalance: raw.agentOpeningBalance || 0, - homeBranch: raw.homeBranch, - tdsApplicable: raw.tdsApplicable === 'Yes', - tdsPercent: raw.tdsPercent || undefined, - savingBranch: raw.savingBranch, - transferBranch: raw.transferBranch || undefined, - transferAmountAccountType: raw.transferAmountAccountType || undefined, - transferAccountNo: raw.transferAccountNo || undefined, - commissionCalculation: raw.commissionCalculation === 'Yes', - limit: raw.limit || undefined, - machineRent: raw.machineRent || undefined, - agentSecurityBranch: raw.agentSecurityBranch || undefined, - agentSecurityAccountType: raw.agentSecurityAccountType || undefined, - agentSecurityAccountNo: raw.agentSecurityAccountNo || undefined, - otherCommission: raw.otherCommission || undefined, - agentDeposit: raw.agentDeposit || undefined, - festivalAdvance: raw.festivalAdvance || undefined, - otherDeduction: raw.otherDeduction || undefined, - dateFormat, - locale, - limits: (raw.limits || []).map((limit: any) => ({ - fromDate: this.dateUtils.getDate(limit.fromDate), - toDate: this.dateUtils.getDate(limit.toDate), - limit: limit.limit - })), - guarantors: (raw.guarantors || []).map((guarantor: any) => ({ - clientId: Number(guarantor.clientId), - guarantorName: guarantor.guarantorName, - address1: guarantor.address1, - address2: guarantor.address2 || undefined, - city: guarantor.city, - pinCode: guarantor.pinCode, - telephone: guarantor.telephone || undefined - })) - }; - - if (this.agentId) { - this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit()); - } else { - this.organizationService.createAgent(payload).subscribe(() => this.exit()); - } - } - - private patchFormFromAgent(agent: any): void { - this.agentId = agent.id; - this.agentForm.patchValue({ - clientId: agent.clientId, - agentCode: agent.agentCode, - agentNameId: agent.agentNameId, - agentAddress1: agent.agentAddress1, - agentAddress2: agent.agentAddress2, - agentCity: agent.agentCity, - agentPinCode: agent.agentPinCode, - agentTelephone: agent.agentTelephone, - agentSavingAccountType: agent.agentSavingAccountType, - agentSavingAccountNo: agent.agentSavingAccountNo, - agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null, - agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null, - agentCurrentBalance: agent.agentCurrentBalance, - agentOpeningBalance: agent.agentOpeningBalance, - homeBranch: agent.homeBranch, - tdsApplicable: agent.tdsApplicable ? 'Yes' : 'No', - tdsPercent: agent.tdsPercent, - currentTds: agent.currentTds, - tdsAmount: agent.tdsAmount, - savingBranch: agent.savingBranch, - transferBranch: agent.transferBranch, - transferAmountAccountType: agent.transferAmountAccountType, - transferAccountNo: agent.transferAccountNo, - commissionCalculation: agent.commissionCalculation ? 'Yes' : 'No', - limit: agent.limit, - machineRent: agent.machineRent, - agentSecurityBranch: agent.agentSecurityBranch, - agentSecurityAccountType: agent.agentSecurityAccountType, - agentSecurityAccountNo: agent.agentSecurityAccountNo, - commission: agent.commission, - otherCommission: agent.otherCommission, - agentDeposit: agent.agentDeposit, - festivalAdvance: agent.festivalAdvance, - otherDeduction: agent.otherDeduction - }); - - this.limits.clear(); - (agent.limits || []).forEach((limit: any) => { - const group = this.buildLimitGroup(); - group.patchValue({ - fromDate: limit.fromDate ? new Date(limit.fromDate) : null, - toDate: limit.toDate ? new Date(limit.toDate) : null, - limit: limit.limit - }); - this.limits.push(group); - }); - - this.guarantors.clear(); - (agent.guarantors || []).forEach((guarantor: any) => { - const group = this.buildGuarantorGroup(); - group.patchValue(guarantor); - this.guarantors.push(group); - }); + money(value: any): string { + return AgentMasterComponent.MONEY.format(Number(value || 0)); } } diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.html b/src/app/organization/agent-master/create-agent/create-agent.component.html new file mode 100644 index 000000000..088295fc1 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.html @@ -0,0 +1,253 @@ + + +
+
+ +

Agent Master Entry

+
+ +
+
+ +
+
+ +

Agent Details

+
+
+ + Client ID + + @if (clientLookupFailed) { + No client found with this ID + } @else if (loadedClientName) { + {{ loadedClientName }} + } + + + + Agent Code + + + + + Agent Name ID + + + + + Agent Address (Line 1) + + + + + Agent Address (Line 2) + + + + + Agent City + + + + + Agent PinCode + + + + + Agent Telephone + + + + + + Home Branch + + @for (office of offices; track office.id) { + {{ office.name }} + } + + The branch this agent's collections belong to + + + + + GL Account + + @for (account of glAccounts; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + + This agent's collections pass through it. One agent to an account. + + + + + Agent Saving A/C Type + + @for (accountType of savingsAccountTypes; track accountType) { + {{ accountType }} + } + + + @if (savingsAccountTypes.length === 0) { + @if (loadedClientName) { + + {{ loadedClientName }} has no savings account — commission can only be paid into one + + } @else { + Enter a Client ID first — types come from that client's own accounts + } + } + + + + + Agent Saving A/C No + + @for (account of accountsOfChosenType; track account.id) { + {{ account.accountNo }} + } + + @if (accountsOfChosenType.length === 0) { + Choose an A/C Type first + } + + + + Agent Opening Date + + + + + + + Agent Birth Date + + + + +
+
+ +
+
+ +

Guarantor's Detail

+ +
+ + @if (guarantors.length === 0) { +

No guarantors added yet.

+ } + + @for (guarantor of guarantors.controls; track guarantor; let i = $index) { +
+ + Client ID + + @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { + An agent cannot be its own guarantor + } + + + Guarantor Name + + + + Address 1 + + + + Address 2 + + + + City + + + + Pin Code + + + + Telephone + + + +
+ } +
+ +
+
+ +

Balances

+
+
+ + Agent Current Balance + + + + + Agent Opening Balance + + +
+
+ +
+ + + + +
+
+
+
diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.scss b/src/app/organization/agent-master/create-agent/create-agent.component.scss new file mode 100644 index 000000000..a0247c6dd --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.scss @@ -0,0 +1,146 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.am-container { + max-width: 1040px; + margin: 0 auto; + padding: 24px 16px; +} + +.am-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.am-back { + color: $primary; +} + +.am-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 8px 24px 24px; +} + +.am-section { + padding: 20px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.am-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.am-field { + width: 100%; + + &--wide { + grid-column: span 2; + } +} + +.am-add-guarantor { + color: $primary; +} + +.am-empty-guarantors { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +.am-guarantor-row { + display: grid; + grid-template-columns: repeat(7, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-limit-row { + display: grid; + grid-template-columns: repeat(3, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-remove-guarantor { + color: #b91c1c; + margin-top: 8px; +} + +.am-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + flex-wrap: wrap; +} + +/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */ +.am-hint--error { + color: #b3261e; +} + +@media (width <= 900px) { + .am-grid { + grid-template-columns: repeat(2, 1fr); + } + + .am-guarantor-row, + .am-limit-row { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.ts b/src/app/organization/agent-master/create-agent/create-agent.component.ts new file mode 100644 index 000000000..50e9b4844 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.ts @@ -0,0 +1,593 @@ +/** + * 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 { + UntypedFormBuilder, + UntypedFormGroup, + UntypedFormArray, + Validators, + ValidatorFn, + AbstractControl, + ValidationErrors +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AccountingService } from 'app/accounting/accounting.service'; +import { SystemService } from 'app/system/system.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** Custom Directives */ +import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; + +/** Writes a value into a form group only when there is something to write. */ +function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + group.get(controlName)?.setValue(String(value)); +} + +/** + * Agent Master Entry component. + * + * A DDS collection agent — either a person or an institution — registered with its own settlement + * savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field + * for field. + * + * Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way + * the legacy screen did, defaults the home branch to that client's office, and stocks the account + * type and account number dropdowns from that client's own accounts. Filled fields stay editable. + * + * Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS + * agent/commission hierarchy being genuinely new feature work. + */ +@Component({ + selector: 'mifosx-create-agent', + templateUrl: './create-agent.component.html', + styleUrls: ['./create-agent.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + // See the Party form: an unimported directive is ignored without a word. + RestrictInputDirective + ] +}) +export class CreateAgentComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private organizationService = inject(OrganizationService); + private clientsService = inject(ClientsService); + private accountingService = inject(AccountingService); + private systemService = inject(SystemService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + /** + * The latest date of birth the picker will offer: eighteen years ago today. + * + * It was previously today, which let a clerk save an agent born on the day they were entered — + * the picker opens on today, so a stray Enter was enough. The figure matches the rule the server + * applies, so the calendar cannot offer a date the save would then refuse. + */ + maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate()); + agentId: number | null = null; + + /** Shown under the Client ID field once a client has been resolved. */ + loadedClientName = ''; + clientLookupFailed = false; + + /** + * The client's own open savings accounts, fetched once the client is resolved. The commission + * account is picked from these rather than typed, so it is always an account that exists and + * always belongs to this agent. + */ + clientSavingsAccounts: any[] = []; + + /** + * The account types this client actually holds, taken from their own accounts rather than a fixed + * list. A fixed list would offer types the client has nothing under, and its wording would have to + * match the product names by hand for the filter below to work at all. + */ + savingsAccountTypes: string[] = []; + + /** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */ + private static readonly SAVINGS_DEPOSIT_TYPE = 100; + + /** Branches, for the Home Branch field. Defaulted from the client's own office. */ + offices: any[] = []; + + /** + * The ledger accounts an agent's collections may pass through. Header accounts are left out: they + * are headings over other accounts and carry no entries, so offering one only leads to a refusal. + */ + glAccounts: any[] = []; + + /** + * The savings product a DDS customer's own scheme account is opened under, read from the setting + * that names it rather than guessed from a product name a bank is free to change. + * + * Held so those accounts can be kept out of the agent's own list. They are savings accounts and + * pass every other test, but they belong to a customer's scheme: commission paid into one would be + * counted as that customer's deposits by every interest run afterwards. The server refuses it + * either way - this is so the choice is never offered rather than taken and then thrown back. + */ + private ddsProductId: number | null = null; + + /** + * The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen + * did, so the officer narrows to a kind of account and then picks one of that kind. + */ + get accountsOfChosenType(): any[] { + const chosenType = this.agentForm?.get('agentSavingAccountType')?.value; + if (!chosenType) { + return []; + } + return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType); + } + + agentForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + agentCode: [ + { + value: '', + disabled: true + } + ], + agentNameId: [ + '', + Validators.required + ], + agentAddress1: [ + '', + Validators.required + ], + agentAddress2: [''], + agentCity: [ + '', + Validators.required + ], + agentPinCode: [ + '', + Validators.required + ], + agentTelephone: [''], + // Which branch this agent collects out of. Holds the office's id, not its name - the name is + // only what the dropdown shows. The DDS batch runs filter by it. + officeId: [ + '', + Validators.required + ], + // Asked before the account number, which is then narrowed to accounts of this type. + agentSavingAccountType: [ + '', + Validators.required + ], + agentSavingAccountNo: [ + '', + Validators.required + ], + agentOpeningDate: [ + '', + Validators.required + ], + agentBirthDate: [ + '', + Validators.required + ], + agentCurrentBalance: [ + { + value: 0, + disabled: true + } + ], + agentOpeningBalance: [0], + /* + * Required. An agent sharing the scheme's one account leaves a balance that says money is + * outstanding without saying whose - which is exactly what this field was added to answer. + */ + glAccountId: [ + '', + Validators.required + ], + guarantors: this.formBuilder.array([]) + }); + + ngOnInit(): void { + this.organizationService.getOffices().subscribe({ + next: (offices: any) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []), + error: () => (this.offices = []) + }); + + this.systemService.getConfigurationByName('dds-deposit-product').subscribe({ + next: (config: any) => { + this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null; + // The client may already have been keyed while this was in flight, so the list is rebuilt + // rather than left showing an account this now knows to exclude. + const clientId = this.agentForm.get('clientId')?.value; + if (clientId) { this.loadClientSavingsAccounts(String(clientId)); } + }, + error: () => (this.ddsProductId = null) + }); + + this.accountingService.getChartOfAccounts().subscribe({ + next: (accounts: any) => { + const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || []; + // usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an + // agent pointed at one would fail on their first collection rather than here. + this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled); + }, + error: () => (this.glAccounts = []) + }); + + // An id in the route means this is an edit; load that agent into the form. + const routeId = this.route.snapshot.paramMap.get('agentId'); + if (routeId) { + this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent)); + } + + // Fill the client-derived fields as soon as typing settles - no need to tab out of the field. + this.agentForm + .get('clientId') + ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) + .subscribe(() => { + this.loadClient(); + // A guarantor row that was fine may now be the agent itself, or the other way round. + for (const row of this.guarantors.controls) { + row.get('clientId')?.updateValueAndValidity(); + } + }); + } + + get guarantors(): UntypedFormArray { + return this.agentForm.get('guarantors') as UntypedFormArray; + } + + private buildGuarantorGroup(): UntypedFormGroup { + const group = this.formBuilder.group({ + clientId: [ + '', + [ + Validators.required, + this.notTheAgentItself() + ] + ], + guarantorName: [ + '', + Validators.required + ], + address1: [ + '', + Validators.required + ], + address2: [''], + city: [ + '', + Validators.required + ], + pinCode: [ + '', + Validators.required + ], + telephone: [''] + }); + + // A guarantor is a client too, so its row fills from the client the same way the agent's does. + group + .get('clientId') + ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) + .subscribe((clientId) => this.loadGuarantorClient(group, clientId)); + + return group; + } + + /** + * An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes + * from someone else. + */ + private notTheAgentItself(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const agentClientId = this.agentForm?.get('clientId')?.value; + if (!control.value || !agentClientId) { + return null; + } + return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null; + }; + } + + /** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */ + private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void { + for (const controlName of ['guarantorName', 'telephone', 'address1', 'address2', 'city', 'pinCode']) { + group.get(controlName)?.setValue(''); + } + if (!clientId) { + return; + } + + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + setGroupField(group, 'guarantorName', client?.displayName); + setGroupField(group, 'telephone', client?.mobileNo); + this.clientsService.getClientAddressData(String(clientId), true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + setGroupField(group, 'address1', address.addressLine1 || address.street); + setGroupField(group, 'address2', address.addressLine2); + setGroupField(group, 'city', address.city); + setGroupField(group, 'pinCode', address.postalCode); + }, + error: () => undefined + }); + }, + // An id that matches no client just leaves the row for the officer to fill in. + error: () => undefined + }); + } + + addGuarantor(): void { + this.guarantors.push(this.buildGuarantorGroup()); + } + + removeGuarantor(index: number): void { + this.guarantors.removeAt(index); + } + + /** The fields the legacy screen filled from the client, rather than asking the officer for. */ + private static readonly CLIENT_DERIVED_FIELDS = [ + 'agentNameId', + 'agentTelephone', + 'agentAddress1', + 'agentAddress2', + 'agentCity', + 'agentPinCode' + ]; + + /** + * Refills the client-derived fields whenever the Client ID changes, as the legacy screen did. + * They always show the client currently entered: changing the id replaces them, clearing it + * empties them. + */ + loadClient(): void { + if (this.agentId) { + return; + } + this.clearClientDerivedFields(); + this.loadedClientName = ''; + this.clientLookupFailed = false; + // A different client has different accounts, so anything picked for the previous one is stale. + this.clientSavingsAccounts = []; + this.savingsAccountTypes = []; + this.agentForm.get('agentSavingAccountType')?.setValue(''); + this.agentForm.get('agentSavingAccountNo')?.setValue(''); + + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + this.loadedClientName = client?.displayName || ''; + this.setField('agentNameId', client?.displayName); + this.setField('agentTelephone', client?.mobileNo); + // The branch the person is on the bank's books at. Offered as a starting point rather than + // forced, since an agent can be recorded at one office and collect out of another. Set as a + // number rather than through setField, whose String() would stop it matching an option. + if (client?.officeId) { + this.agentForm.get('officeId')?.setValue(Number(client.officeId)); + } + this.loadClientAddress(String(clientId)); + this.loadClientSavingsAccounts(String(clientId)); + }, + error: () => { + this.clientLookupFailed = true; + } + }); + } + + /** + * The client's own open savings accounts, to be picked from rather than typed. + * + * The account number used to be free text, and the live data showed the cost: agent AG0001 carried + * "1234567890", which matches no account at all - so every commission recorded against it had + * nowhere to go. Offering only the client's real accounts makes that impossible to key, and the + * account number then carries its correct product prefix by construction rather than by memory. + */ + private loadClientSavingsAccounts(clientId: string): void { + this.clientsService.getClientAccountData(clientId).subscribe({ + next: (accounts: any) => { + this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter( + (account: any) => + // Savings only. Commission is credited whenever a run posts, and a fixed deposit is a + // single sum placed for a term while a recurring one takes only its own instalments - + // neither accepts the credit, so the posting would fail rather than the choice. + // A DDS scheme account is a savings account too and so passes the test above. It is + // dropped by the product the setting names - not by a product called "DDS", which is a + // name the bank may change - so the list offers only accounts the agent can be paid in. + account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE && + (this.ddsProductId === null || account.productId !== this.ddsProductId) && + (account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved) + ); + this.savingsAccountTypes = [ + ...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean)) + ]; + }, + error: () => { + this.clientSavingsAccounts = []; + this.savingsAccountTypes = []; + } + }); + } + + /** + * Clears the account number when the type changes. + * + * Without this the previously picked account stays in the field while the list below it now shows + * a different type - the form would submit an account that contradicts its own type. + */ + onSavingsAccountTypePicked(): void { + this.agentForm.get('agentSavingAccountNo')?.setValue(''); + } + + private loadClientAddress(clientId: string): void { + this.clientsService.getClientAddressData(clientId, true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + this.setField('agentAddress1', address.addressLine1 || address.street); + this.setField('agentAddress2', address.addressLine2); + this.setField('agentCity', address.city); + this.setField('agentPinCode', address.postalCode); + }, + // A client with no address on file is normal — the officer just types it. + error: () => undefined + }); + } + + private clearClientDerivedFields(): void { + for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) { + this.agentForm.get(controlName)?.setValue(''); + } + } + + private setField(controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + this.agentForm.get(controlName)?.setValue(String(value)); + } + + retrieve(): void { + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + this.organizationService.getAgents().subscribe((agents: any[]) => { + const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId)); + if (match) { + this.patchFormFromAgent(match); + } + }); + } + + cancel(): void { + this.agentId = null; + this.loadedClientName = ''; + this.clientLookupFailed = false; + this.agentForm.reset(); + this.agentForm.patchValue({ + agentCurrentBalance: 0, + agentOpeningBalance: 0 + }); + this.guarantors.clear(); + } + + exit(): void { + this.router.navigate(['/organization/agent-master']); + } + + save(): void { + const raw = this.agentForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + + const payload: any = { + clientId: Number(raw.clientId), + agentNameId: raw.agentNameId, + agentAddress1: raw.agentAddress1, + agentAddress2: raw.agentAddress2 || undefined, + agentCity: raw.agentCity, + agentPinCode: raw.agentPinCode, + agentTelephone: raw.agentTelephone || undefined, + officeId: Number(raw.officeId), + glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined, + agentSavingAccountType: raw.agentSavingAccountType, + agentSavingAccountNo: raw.agentSavingAccountNo, + agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat), + agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat), + agentOpeningBalance: raw.agentOpeningBalance || 0, + dateFormat, + locale, + guarantors: (raw.guarantors || []).map((guarantor: any) => ({ + clientId: Number(guarantor.clientId), + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2 || undefined, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone || undefined + })) + }; + + if (this.agentId) { + this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit()); + } else { + this.organizationService.createAgent(payload).subscribe(() => this.exit()); + } + } + + private patchFormFromAgent(agent: any): void { + this.agentId = agent.id; + this.loadedClientName = agent.clientName || ''; + this.clientLookupFailed = false; + this.agentForm.patchValue({ + clientId: agent.clientId, + agentCode: agent.agentCode, + agentNameId: agent.agentNameId, + agentAddress1: agent.agentAddress1, + agentAddress2: agent.agentAddress2, + agentCity: agent.agentCity, + agentPinCode: agent.agentPinCode, + agentTelephone: agent.agentTelephone, + officeId: agent.officeId ? Number(agent.officeId) : '', + glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '', + agentSavingAccountType: agent.agentSavingAccountType, + agentSavingAccountNo: agent.agentSavingAccountNo, + agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null, + agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null, + agentCurrentBalance: agent.agentCurrentBalance, + agentOpeningBalance: agent.agentOpeningBalance + }); + + // The account list is needed for the dropdown to show the already-saved account as chosen + // rather than blank, so it is fetched on edit as well as on the client lookup. + if (agent.clientId) { + this.loadClientSavingsAccounts(String(agent.clientId)); + } + + this.guarantors.clear(); + for (const guarantor of agent.guarantors || []) { + const group = this.buildGuarantorGroup(); + group.patchValue({ + clientId: guarantor.clientId, + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone + }); + this.guarantors.push(group); + } + } +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.html b/src/app/organization/commission-slab-master/commission-slab-master.component.html index 31c5b682b..b55152475 100644 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.html +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.html @@ -27,17 +27,67 @@ @for (slab of slabs; track slab; let i = $index) { - - {{ slab.slabType }} - {{ slab.slabFrom }} - {{ slab.slabTo }} - {{ slab.commissionPercent }}% - - - - + @if (editingIndex === i) { + + + + + + @for (type of slabTypes; track type) { + {{ type }} + } + + + + + + + + + + + + + + + + + + + + + + + + } @else { + + {{ slab.slabType }} + {{ slab.slabFrom }} + {{ slab.slabTo }} + {{ slab.commissionPercent }}% + + @if (slab.id) { + + + } @else { + + + } + + + } } @@ -66,11 +116,15 @@ + diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.scss b/src/app/organization/commission-slab-master/commission-slab-master.component.scss index 76eed8f2a..7ecabf415 100644 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.scss +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.scss @@ -9,7 +9,8 @@ $primary: #0f766e; .csm-container { - max-width: 760px; + // Four inputs sit side by side in the entry row; 760px squeezed them out of the card. + max-width: 980px; margin: 0 auto; padding: 24px 16px; } @@ -41,6 +42,14 @@ $primary: #0f766e; .csm-table { width: 100%; border-collapse: collapse; + table-layout: fixed; + + // Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in. + th:nth-child(1), td:nth-child(1) { width: 26%; } + th:nth-child(2), td:nth-child(2) { width: 22%; } + th:nth-child(3), td:nth-child(3) { width: 22%; } + th:nth-child(4), td:nth-child(4) { width: 18%; } + th:nth-child(5), td:nth-child(5) { width: 12%; } th { text-align: left; @@ -61,6 +70,8 @@ $primary: #0f766e; .csm-inline-field { width: 100%; + // Without this the field holds its own 180px floor and pushes the next column out of the card. + min-width: 0; ::ng-deep .mat-mdc-text-field-wrapper { height: 44px; @@ -90,3 +101,44 @@ $primary: #0f766e; padding-top: 16px; border-top: 1px solid rgb(0 0 0 / 8%); } + +/* + * What the slabs actually cover, and any figure between them that none of them claims. + * + * A gap is legitimate - a bank may set no commission below some floor - so this reports rather than + * warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and + * moves on, so the agent is simply paid nothing that month and nothing on screen explains it. + */ +.csm-coverage { + margin-top: 14px; + padding: 12px 16px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 6px; + background: rgb(0 0 0 / 2.5%); + display: flex; + flex-direction: column; + gap: 6px; + + &__row { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + font-size: 0.86rem; + } + + &__type { + font-weight: 600; + min-width: 90px; + } + + &__range { + color: rgb(0 0 0 / 62%); + font-variant-numeric: tabular-nums; + } + + &__gap { + color: #b45309; + font-variant-numeric: tabular-nums; + } +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.ts b/src/app/organization/commission-slab-master/commission-slab-master.component.ts index 563a0da46..c1a96c849 100644 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.ts +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.ts @@ -14,6 +14,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Commission Slab Master component. @@ -39,6 +40,7 @@ export class CommissionSlabMasterComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); private static readonly SLAB_TYPE_CODES: Record = { Account: 'ACCOUNT', @@ -99,11 +101,85 @@ export class CommissionSlabMasterComponent implements OnInit { }); } + /** + * Where each slab type's coverage begins and ends, and any figure in between that no slab claims. + * + * A gap is not an error - a bank may well decide there is no commission below some floor - but it + * has to be visible. The commission run simply finds no slab for a volume that falls in a gap and + * moves on, so the agent is paid nothing for that month with nothing on screen to say why. + */ + get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> { + return this.slabTypes + .map((slabType) => { + const rows = this.slabs + .filter((slab) => slab.slabType === slabType) + .map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) })) + .sort((a, b) => a.from - b.from); + + if (rows.length === 0) { + return null; + } + + const gaps: string[] = []; + for (let i = 1; i < rows.length; i++) { + const previousEnd = rows[i - 1].to; + const nextStart = rows[i].from; + if (nextStart > previousEnd + 1) { + gaps.push(`${previousEnd + 1} - ${nextStart - 1}`); + } + } + + return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps }; + }) + .filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null); + } + + /** + * The slab already on the table that this range would collide with, if any. + * + * The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may + * have keyed several rows and has to work out which one it objected to. Catching it as the row is + * added names the clash while it is still the row in front of them. + */ + private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any { + return this.slabs.find( + (slab) => + slab.slabType === candidate.slabType + && Number(candidate.slabFrom) <= Number(slab.slabTo) + && Number(candidate.slabTo) >= Number(slab.slabFrom) + ); + } + addSlab(): void { if (this.newSlabForm.invalid) { + // Marking the controls touched is what makes Material show which one is missing; without it + // the button simply does nothing and the officer has no idea what the screen is waiting for. + this.newSlabForm.markAllAsTouched(); + this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' }); return; } - this.slabs.push(this.newSlabForm.getRawValue()); + + const candidate = this.newSlabForm.getRawValue(); + + if (Number(candidate.slabFrom) > Number(candidate.slabTo)) { + this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' }); + return; + } + if (Number(candidate.commissionPercent) > 100) { + this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' }); + return; + } + + const clash = this.clashingSlab(candidate); + if (clash) { + this.alertService.alert({ + type: 'Error', + message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}` + }); + return; + } + + this.slabs.push(candidate); this.newSlabForm.reset({ slabType: '', slabFrom: 0, @@ -112,10 +188,92 @@ export class CommissionSlabMasterComponent implements OnInit { }); } + /** Only ever an unsaved row - a saved slab is corrected, not taken away. */ removeSlab(index: number): void { this.slabs.splice(index, 1); } + /** Which saved row is currently open for correction, or -1 when none is. */ + editingIndex = -1; + + editSlabForm: UntypedFormGroup = this.formBuilder.group({ + slabType: [ + '', + Validators.required + ], + slabFrom: [ + 0, + Validators.required + ], + slabTo: [ + 0, + Validators.required + ], + commissionPercent: [ + 0, + Validators.required + ] + }); + + startEdit(index: number): void { + const slab = this.slabs[index]; + this.editingIndex = index; + this.editSlabForm.setValue({ + slabType: slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + }); + } + + cancelEdit(): void { + this.editingIndex = -1; + } + + saveEdit(): void { + const slab = this.slabs[this.editingIndex]; + const corrected = this.editSlabForm.getRawValue(); + + if (this.editSlabForm.invalid) { + this.editSlabForm.markAllAsTouched(); + this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' }); + return; + } + if (Number(corrected.slabFrom) > Number(corrected.slabTo)) { + this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' }); + return; + } + if (Number(corrected.commissionPercent) > 100) { + this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' }); + return; + } + + this.organizationService + .updateCommissionSlab(slab.id, { + slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType, + slabFrom: corrected.slabFrom, + slabTo: corrected.slabTo, + commissionPercent: corrected.commissionPercent, + locale: 'en' + }) + .subscribe({ + next: () => { + this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); + this.editingIndex = -1; + this.loadSlabs(); + }, + // The backend refuses a correction that overlaps another slab; the reason has to reach the + // officer, not vanish. + error: (error: any) => { + this.alertService.alert({ + type: 'Error', + message: + error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab' + }); + } + }); + } + exit(): void { this.router.navigate(['/organization']); } @@ -123,6 +281,12 @@ export class CommissionSlabMasterComponent implements OnInit { save(): void { const newSlabs = this.slabs.filter((slab) => !slab.id); if (newSlabs.length === 0) { + // Save used to return here without a word, so a slab typed into the entry row but never added + // looked exactly like a slab that had been saved. + this.alertService.alert({ + type: 'Error', + message: 'Nothing new to save — add the slab to the table with + first' + }); return; } @@ -133,6 +297,23 @@ export class CommissionSlabMasterComponent implements OnInit { commissionPercent: slab.commissionPercent })); - this.organizationService.createCommissionSlabs(payload).subscribe(() => this.loadSlabs()); + this.organizationService.createCommissionSlabs(payload).subscribe({ + next: () => { + this.alertService.alert({ + type: 'Success', + message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved` + }); + this.loadSlabs(); + }, + // The backend refuses an overlapping range; the officer needs to read why, not watch nothing + // happen and assume the save went through. + error: (error: any) => { + this.alertService.alert({ + type: 'Error', + message: + error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs' + }); + } + }); } } diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html index be20aba12..ccd216bd7 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html @@ -11,7 +11,7 @@ -

DDS Interest & Commission Batch Processing

+

DDS Interest & Commission

@@ -20,8 +20,8 @@ Job Type - @for (type of jobTypes; track type) { - {{ type }} + @for (type of jobTypes; track type.code) { + {{ type.label }} } @@ -49,7 +49,7 @@ } - {{ isPostingJob ? 'Posting Date' : 'As-On Date' }} + {{ isPostingJob ? 'Posting Date' : 'Interest Date' }} @@ -65,69 +65,234 @@ - - Party From - - + + @if (!isCommissionJob) { + + Party From + + - - Party To - - + + Party To + + + }
- Generate runs a preview only — nothing is applied to any account. Post is a - separate, explicit step that commits the previewed amounts. + @if (isPostingJob) { + {{ jobTypeLabel }} moves what has already been calculated to where it belongs — into the + customer's balance, or out to the agent. Generate lists it first; nothing moves until you + press Post. + } @else { + {{ jobTypeLabel }} works out what has been earned since the last run and records it against + each account. It does not become spendable until the matching Posting job runs. + Generate previews it; nothing is recorded until you press Post. + }
- @if (hasGenerated) { + @if (generatedRun) {
-

Preview

+

{{ labelOf(generatedRun.jobType) }} — preview

+ + Run #{{ generatedRun.id }} · nothing applied yet +
+ @if (previewRows.length === 0) {

- No calculation preview available yet — this appears once the backend batch-job endpoint for "{{ - batchForm.get('jobType').value - }}" is connected. + Nothing to apply. Either this period has already been processed, or there was no activity in it.

} @else { - - - - - - - - - - @for (row of previewRows; track row) { - - - - - - } - -
AgentPartyAmount
{{ row.agent }}{{ row.party }}{{ row.amount | number: '1.2-2' }}
+
+ @if (showsCommissionBreakdown) { + + + + + + + + + + + + + + + @for (row of previewRows; track row.id) { + + + + + + + + + + + } + + + + + + + +
AgentPeriodCollectionRateCommissionTDSDeductionsPayable
+ {{ row.agentCode }} + {{ row.agentName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }}{{ row.ratePercent }}%{{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }}
Total payable{{ money(previewTotal) }}
+ } @else { + + + + + @if (!isCommissionJob) { + + } + + + + + + @for (row of previewRows; track row.id) { + + + @if (!isCommissionJob) { + + } + + + + } + + + + + + + +
{{ isCommissionJob ? 'Agent' : 'Party' }}AgentPeriodAmount
+ @if (isCommissionJob) { + {{ row.agentCode }} + {{ row.agentName }} + } @else { + {{ row.partyCode }} + {{ row.partyName }} + } + {{ row.agentCode }}{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.amount) }}
Total{{ money(previewTotal) }}
+ } +
}
}
+ @if (generatedRun) { + + } -
+ + + @if (postingRunId) { +
+ + + Posting is running. + It continues even if you leave this screen — the list below updates when it finishes. + +
+ } + + +
+
+ +

Previous runs

+
+ + @if (recentRuns.length === 0) { +

No batch has been run yet.

+ } @else { +
+ + + + + + + + + + + + + + @for (run of recentRuns; track run.id) { + + + + + + + + + + + } + +
#JobDateEntriesAmountStatusPosted On
{{ run.id }}{{ labelOf(run.jobType) }}{{ run.jobDate | dateFormat }}{{ run.totalRows }}{{ money(run.totalAmount) }} + @switch (run.status) { + @case ('POSTED') { + Posted + } + @case ('POSTING') { + + Running + + } + @case ('FAILED') { + + Failed + + } + @default { + Not posted + } + } + {{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}
+
+ } +
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss index 499ff754e..26dbefd94 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss @@ -9,16 +9,20 @@ $primary: #0f766e; .bp-container { - max-width: 900px; + // The commission breakdown carries eight columns; anything narrower makes it scroll on every + // screen rather than only on small ones. + max-width: 1180px; margin: 0 auto; padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 20px; } .bp-header { display: flex; align-items: center; gap: 16px; - margin-bottom: 20px; &__title { margin: 0; @@ -95,8 +99,15 @@ $primary: #0f766e; margin: 0; font-size: 16px; font-weight: 600; + flex: 0 0 auto; } } + + &__note { + margin-left: auto; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } } .bp-empty { @@ -105,23 +116,118 @@ $primary: #0f766e; margin: 0; } +// A wide table scrolls inside its own box rather than pushing the whole page sideways. +.bp-table-scroll { + overflow-x: auto; +} + .bp-table { width: 100%; border-collapse: collapse; + font-size: 13px; th { text-align: left; - font-size: 13px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: rgb(0 0 0 / 55%); padding: 8px 10px; border-bottom: 2px solid rgb(0 0 0 / 10%); + white-space: nowrap; } td { - padding: 8px 10px; + padding: 7px 10px; border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: top; + } + + tfoot td { + border-bottom: none; + border-top: 2px solid rgb(0 0 0 / 10%); + font-weight: 600; + } + + // Figures line up column-wise so a wrong order of magnitude is visible at a glance. + .bp-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .bp-minus { + color: #b91c1c; + } + + .bp-total { + font-weight: 600; + } + + .bp-period { + white-space: nowrap; + color: rgb(0 0 0 / 60%); + } +} + +.bp-code { + display: block; + font-weight: 600; +} + +.bp-sub { + display: block; + font-size: 12px; + color: rgb(0 0 0 / 55%); +} + +.bp-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + background: rgb(180 83 9 / 12%); + color: #92400e; + + &--posted { + background: rgb(15 118 110 / 12%); + color: $primary; + } + + /* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand, + the other is work forgotten, and a clerk who reads them the same way runs it twice. */ + &--running { + background: rgb(22 84 143 / 12%); + color: #16548f; + display: inline-flex; + align-items: center; + gap: 5px; + } + + &--failed { + background: rgb(163 42 28 / 12%); + color: #a32a1c; + cursor: help; + } +} + +/* Says a run is still going after the clerk has left the button behind. */ +.bp-running { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid rgb(22 84 143 / 25%); + border-left: 3px solid #16548f; + border-radius: 6px; + background: rgb(22 84 143 / 6%); + color: #16548f; + font-size: 13px; + + strong { + font-weight: 600; } } @@ -132,6 +238,10 @@ $primary: #0f766e; margin-top: 20px; padding-top: 16px; border-top: 1px solid rgb(0 0 0 / 8%); + + i { + margin-right: 6px; + } } @media (width <= 900px) { diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts index 2b6bf5cad..a4ed266a6 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -10,60 +10,70 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; /** - * DDS Interest & Commission Batch Processing component. + * DDS Interest & Commission batch processing — the legacy Interest Part menu. * - * Six batch job types, each following the same deliberate two-step - * pattern from the legacy system: Calculate first (a dry-run preview), - * then Post as a separate, explicit confirmation — so a wrong calculation - * is never applied by accident. Modelled on the legacy Interest - * Calculation screen this replaces. + * Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has + * been earned since the last one and parks it: interest against the customer's account, commission + * against the agent. Neither is spendable yet. The matching *posting* job then moves that parked + * figure where it belongs — into the customer's balance, or out to the agent. * - * Backed by the DDS Batch Run API (`/dds/batchruns`). Only the three - * "Calculation" job types can actually be generated - the three "Posting" - * entries exist in the dropdown for parity with the legacy menu, but Post - * always applies whatever run was most recently generated in this session, - * regardless of which dropdown entry is currently selected (matching how - * the Post button here is only ever gated on hasGenerated, not on jobType). + * Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes + * nothing else, so a figure that looks wrong is discarded by simply not posting it. */ @Component({ selector: 'mifosx-dds-batch-processing', templateUrl: './dds-batch-processing.component.html', styleUrls: ['./dds-batch-processing.component.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + // The reason a run failed is too long to sit in the column, and too useful to leave out. + MatTooltip + ], + // Day-first dates, as the legacy screen showed them. Declared here rather than application-wide: + // a @Component's providers reach only this component, so no other screen's datepicker changes. + providers: [ + { provide: DateAdapter, useClass: DdsDateAdapter }, + { provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS } + ] }) export class DdsBatchProcessingComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + private dialog = inject(MatDialog); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); - private static readonly JOB_TYPE_CODES: Record = { - 'Interest Calculation': 'INTEREST_CALCULATION', - 'OD Interest Calculation': 'OD_INTEREST_CALCULATION', - 'Commission Calculation': 'COMMISSION_CALCULATION' - }; - - private currentBatchRunId: number | null = null; - - offices: any[] = []; - selectedOfficeNames = new Set(); - - jobTypes = [ - 'Interest Calculation', - 'Interest Posting', - 'OD Interest Calculation', - 'OD Interest Posting', - 'Commission Calculation', - 'Commission Posting' + /** + * The menu as the legacy system lists it, paired with what the API calls each job. Ordered as + * calculate-then-post within each pair, matching the numbering on the legacy screen. + */ + jobTypes: Array<{ label: string; code: string }> = [ + { label: 'Interest Calculation', code: 'INTEREST_CALCULATION' }, + { label: 'Interest Posting', code: 'INTEREST_POSTING' }, + { label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' }, + { label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' }, + { label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' }, + { label: 'Commission Posting', code: 'COMMISSION_POSTING' }, + // Last, and on its own: the others move money onto accounts that stay open, while this one pays + // an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the + // second half of one. + { label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' } ]; branchScopes = [ @@ -71,12 +81,23 @@ export class DdsBatchProcessingComponent implements OnInit { 'Selected' ]; - hasGenerated = false; - previewRows: Array<{ agent: string; party: string; amount: number }> = []; + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + offices: any[] = []; + selectedOfficeNames = new Set(); + + /** The run currently previewed on screen, or null once it has been posted or discarded. */ + generatedRun: any = null; + previewRows: any[] = []; + + recentRuns: any[] = []; batchForm: UntypedFormGroup = this.formBuilder.group({ jobType: [ - 'Interest Calculation', + 'INTEREST_CALCULATION', Validators.required ], branchScope: [ @@ -105,18 +126,56 @@ export class DdsBatchProcessingComponent implements OnInit { ] }); + get jobType(): string { + return this.batchForm.get('jobType').value; + } + + get jobTypeLabel(): string { + return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType; + } + + /** Commission is per agent, so the party range does not narrow it. */ + get isCommissionJob(): boolean { + return this.jobType.startsWith('COMMISSION'); + } + get isPostingJob(): boolean { - return (this.batchForm.get('jobType').value as string).includes('Posting'); + return this.jobType.endsWith('_POSTING'); } get isBranchSelected(): boolean { return this.batchForm.get('branchScope')?.value === 'Selected'; } + /** + * Only a commission calculation has a breakdown worth showing: it is the one job where the figure + * applied is not the figure worked out, because TDS and the standing deductions come off it. + */ + get showsCommissionBreakdown(): boolean { + return this.jobType === 'COMMISSION_CALCULATION'; + } + + /** What Generate found, in words — the same figure the Post button is about to apply. */ + get previewTotal(): number { + return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0); + } + ngOnInit(): void { this.organizationService.getOffices().subscribe((offices: any[]) => { this.offices = offices || []; }); + this.loadRecentRuns(); + } + + money(value: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + return DdsBatchProcessingComponent.MONEY.format(Number(value)); + } + + labelOf(code: string): string { + return this.jobTypes.find((type) => type.code === code)?.label ?? code; } toggleOffice(officeName: string): void { @@ -127,59 +186,202 @@ export class DdsBatchProcessingComponent implements OnInit { } } + /** + * What every past run did and whether it was applied. Without this a generated-but-unposted run is + * invisible the moment the page is left, and there is no way to tell whether a month's interest + * was ever actually posted. + */ + private loadRecentRuns(): void { + this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => { + this.recentRuns = runs || []; + }); + } + generate(): void { const raw = this.batchForm.getRawValue(); - const jobType = DdsBatchProcessingComponent.JOB_TYPE_CODES[raw.jobType]; - - if (!jobType) { - // A "Posting" entry was selected - there is nothing to calculate for - // it, only to post (see class-level note on how Post is wired). - this.hasGenerated = true; - this.previewRows = []; - return; - } - const dateFormat = this.settingsService.dateFormat; + const payload: any = { - jobType, + jobType: raw.jobType, branchScope: raw.branchScope, jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat), agentFrom: raw.agentFrom, agentTo: raw.agentTo, - partyFrom: raw.partyFrom, - partyTo: raw.partyTo, dateFormat, locale: this.settingsService.language.code }; + // The party range narrows an interest job to particular accounts; commission is worked out per + // agent on their whole collection, so sending a range there would only imply a filter that has + // no effect. + if (!this.isCommissionJob) { + payload.partyFrom = raw.partyFrom; + payload.partyTo = raw.partyTo; + } + if (raw.branchScope === 'Selected') { payload.officeNames = Array.from(this.selectedOfficeNames); } this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => { - this.currentBatchRunId = result.resourceId; - this.organizationService.getDdsBatchRun(this.currentBatchRunId).subscribe((batchRun: any) => { - this.previewRows = (batchRun.rows || []).map((row: any) => ({ - agent: row.agentCode, - party: row.partyCode || '—', - amount: row.amount - })); - this.hasGenerated = true; + this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => { + this.generatedRun = batchRun; + this.previewRows = batchRun.rows || []; + + // A run that found nothing is the normal outcome of a period already processed, not a + // failure — but it has to say so, or an empty table reads as the screen being broken. + if (this.previewRows.length === 0) { + this.alertService.alert({ + type: 'Information', + message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.` + }); + } else { + this.alertService.alert({ + type: 'Success', + message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.` + }); + } + + this.loadRecentRuns(); }); }); } post(): void { - if (!this.currentBatchRunId) { + if (!this.generatedRun || this.previewRows.length === 0) { return; } - this.organizationService.postDdsBatchRun(this.currentBatchRunId).subscribe(() => { - this.currentBatchRunId = null; - this.hasGenerated = false; + + const count = this.previewRows.length; + const total = this.money(this.previewTotal); + const isPosting = this.isPostingJob; + + // Post is the step that moves money, and it cannot be undone from this screen — so it names + // exactly what is about to change rather than asking a generic "are you sure". + this.dialog + .open(ConfirmationDialogComponent, { + data: { + type: 'warn', + heading: `Post ${this.jobTypeLabel}?`, + dialogContext: + `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` + + (isPosting + ? 'This credits the customers or pays the agents, and appears on the ledger. ' + : 'This records what has been earned against each account. ') + + 'It cannot be undone from this screen.' + } + }) + .afterClosed() + .subscribe((response: any) => { + if (response?.confirm) { + this.applyRun(count, total); + } + }); + } + + /** + * Applies the run, and says what actually happened rather than assuming it finished. + * + * A crediting run costs what a savings deposit costs on every account it touches, so the server + * takes it off the request and does it in the background - the reply comes back in a moment while + * the work is still going on. Reporting that as "posted" would tell the clerk a run had finished + * seconds after it began, and they would go looking for credits that were still being written. + * + * So the run is followed until it settles. A calculation run is done by the time the reply lands + * and says so at once; a crediting run says it is under way, and the list beneath refreshes itself + * until the server marks it posted - or failed, with the reason the server recorded. + */ + private applyRun(count: number, total: string): void { + const label = this.jobTypeLabel; + const runId = this.generatedRun.id; + const crediting = this.isPostingJob; + + this.organizationService.postDdsBatchRun(runId).subscribe(() => { + this.generatedRun = null; this.previewRows = []; + this.loadRecentRuns(); + + if (!crediting) { + this.alertService.alert({ + type: 'Success', + message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.` + }); + return; + } + + this.postingRunId = runId; + this.alertService.alert({ + type: 'Posting', + message: `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` + + 'It continues in the background; this list will update when it is done.' + }); + this.followRun(runId, label); }); } + /** The crediting run being followed, if one is. The screen reads this to show it is under way. */ + postingRunId: number | null = null; + + /** + * Watches a background run until the server marks it settled. + * + * Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll + * costs nothing when the answer is one row. It gives up after a while rather than asking forever - + * the run is still on the list either way, and a clerk can read its status there. + */ + private followRun(runId: number, label: string): void { + let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS; + + const check = () => { + this.organizationService.getDdsBatchRuns().subscribe({ + next: (runs: any[]) => { + const run = (runs || []).find((each: any) => each.id === runId); + const status = run?.status; + + if (status === 'POSTED' || status === 'FAILED') { + this.postingRunId = null; + this.loadRecentRuns(); + this.alertService.alert( + status === 'POSTED' + ? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` } + : { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` } + ); + return; + } + + if (--checksLeft > 0) { + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } else { + // Still going after the wait. Left on the list rather than reported either way - saying + // "failed" for a run that is merely long would be worse than saying nothing. + this.postingRunId = null; + this.loadRecentRuns(); + } + }, + // A failed status check says nothing about the run itself, which carries on regardless. + error: () => { + if (--checksLeft > 0) { + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } else { + this.postingRunId = null; + } + } + }); + }; + + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } + + private static readonly STATUS_CHECK_INTERVAL_MS = 4000; + /** Twenty minutes of checking, which is longer than any run measured here has taken. */ + private static readonly MAX_STATUS_CHECKS = 300; + + /** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */ + discard(): void { + this.generatedRun = null; + this.previewRows = []; + } + exit(): void { this.router.navigate(['/organization']); } diff --git a/src/app/organization/dds-batch-processing/dds-date-adapter.ts b/src/app/organization/dds-batch-processing/dds-date-adapter.ts new file mode 100644 index 000000000..10d4dd8b5 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-date-adapter.ts @@ -0,0 +1,71 @@ +/** + * 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 { Injectable } from '@angular/core'; +import { NativeDateAdapter, MatDateFormats } from '@angular/material/core'; + +/** + * Day-first dates for the DDS batch screen. + * + * Angular Material's stock adapter shows and reads dates the American way — 12/31/2026 — and its + * parser hands typed input to `Date.parse`, which cannot read a day-first date at all: typing + * 31/12/2026 yields no date, so the field silently empties and reports itself invalid. + * + * On a screen where the date decides which period's interest is calculated, both halves matter: a + * clerk who reads 12/31 as the 12th of some month picks the wrong period, and one who types the date + * they are used to gets an error with nothing to explain it. + * + * Scoped to this component rather than provided application-wide. Every other datepicker in the app + * has the same behaviour, and changing it globally would alter screens outside DDS. + */ +@Injectable() +export class DdsDateAdapter extends NativeDateAdapter { + /** Matches d/m/yyyy, with or without leading zeros, on / . or - separators. */ + private static readonly DAY_FIRST = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/; + + override parse(value: any): Date | null { + if (typeof value === 'string') { + const match = DdsDateAdapter.DAY_FIRST.exec(value.trim()); + if (match) { + const day = Number(match[1]); + const month = Number(match[2]); + const year = Number(match[3]); + + const parsed = new Date(year, month - 1, day); + // JavaScript rolls an out-of-range day into the next month rather than rejecting it, so + // 31/02/2026 would quietly become the 3rd of March. Reading the parts back catches that. + const isRealDate = + parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day; + return isRealDate ? parsed : null; + } + } + return super.parse(value); + } + + override format(date: Date, displayFormat: any): string { + if (displayFormat === 'input') { + return `${this.pad(date.getDate())}/${this.pad(date.getMonth() + 1)}/${date.getFullYear()}`; + } + return super.format(date, displayFormat); + } + + private pad(value: number): string { + return value < 10 ? `0${value}` : `${value}`; + } +} + +/** Tells the datepicker to render the field itself day-first; the calendar keeps its usual labels. */ +export const DDS_DATE_FORMATS: MatDateFormats = { + parse: { dateInput: 'input' }, + display: { + dateInput: 'input', + monthYearLabel: { year: 'numeric', month: 'short' }, + dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' }, + monthYearA11yLabel: { year: 'numeric', month: 'long' } + } +}; diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 0ddb76ce3..64fb7f7ba 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -18,6 +18,7 @@ import { OrganizationComponent } from './organization.component'; import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/loan-provisioning-criteria.component'; import { OfficesComponent } from './offices/offices.component'; import { AgentMasterComponent } from './agent-master/agent-master.component'; +import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component'; import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component'; import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { EmployeesComponent } from './employees/employees.component'; @@ -242,8 +243,23 @@ const routes: Routes = [ }, { path: 'agent-master', - component: AgentMasterComponent, - data: { title: 'Agent Master', breadcrumb: 'Agent Master' } + data: { title: 'Agent Master', breadcrumb: 'Agent Master' }, + children: [ + { + path: '', + component: AgentMasterComponent + }, + { + path: 'create', + component: CreateAgentComponent, + data: { title: 'Create Agent', breadcrumb: 'Create' } + }, + { + path: ':agentId/edit', + component: CreateAgentComponent, + data: { title: 'Edit Agent', breadcrumb: 'Edit', routeParamBreadcrumb: false } + } + ] }, { path: 'commission-slab-master', diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 6aab81df1..44e296c6e 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -272,6 +272,17 @@ export class OrganizationService { return this.http.get('/dds/parties'); } + /** + * The parties a given agent collects for — what the daily transaction screen works from, since + * it handles one agent's round at a time. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} That agent's DDS Parties. + */ + getPartiesOfAgent(agentId: string | number): Observable { + return this.http.get('/dds/parties', { params: new HttpParams().set('agentId', String(agentId)) }); + } + /** * @param {string | number} partyId Party ID. * @returns {Observable} DDS Party. @@ -306,6 +317,65 @@ export class OrganizationService { return this.http.post(`/dds/parties/${partyId}?command=close`, closeCommand); } + /** + * The DDS accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. Nothing closes itself: a matured account is money the bank owes the customer, + * and where it goes has to be agreed with them. + * + * @returns {Observable} Accounts due for closure, oldest maturity first. + */ + getPartiesDueForClosure(): Observable { + return this.http.get('/dds/parties/dueforclosure'); + } + + /** + * The customer's own accounts a DDS maturity can be paid into - active savings accounts of theirs, + * with fixed and recurring deposits and their other DDS scheme accounts left out. + * + * The server applies the same conditions the payout itself does, so this list can never offer a + * choice that is then refused on save. Filtering here instead meant the two drifted apart: the + * accounts on offer once included a scheme account of a party that had already closed. + * + * @param {string | number} clientId Client ID. + * @returns {Observable} id, accountNo, productName and balance for each. + */ + getCreditableAccounts(clientId: string | number): Observable { + return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } }); + } + + /** + * Pays a matured DDS account out and closes it. What is paid is what the account holds, not the + * figure quoted at opening. + * + * @param {string | number} partyId Party ID. + * @param {any} payoutCommand { toSavingsAccountNo } - another savings account of the same customer + * to move the money into. Omit it when they have none here: the money is then parked in the + * matured-deposits payable account for the branch to send on to their account at another bank. + * @returns {Observable} + */ + payOutParty(partyId: string | number, payoutCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=payout`, payoutCommand); + } + + /** + * Closes a DDS account before its term is up, and pays out what is left - one act, unlike a + * maturity payout, which leaves the account open for the clerk to close separately. + * + * @param {string | number} partyId Party ID. + * @param {any} closeCommand { shortfallBorneBy, closeDate, dateFormat, locale, toSavingsAccountNo? }. + * closeDate, dateFormat and locale go up exactly as they do on an ordinary close. + * shortfallBorneBy is mandatory + * and is CUSTOMER (their interest is recalculated at the scheme's reduced rate and the + * difference taken back) or AGENT (the customer keeps a full month's interest and the agent + * funds the days left in it). The agent is charged a share of their commission either way. + * toSavingsAccountNo works as it does for a payout. The lock-in, the rate reduction and the + * agent's share come from the scheme and are applied by the server. + * @returns {Observable} + */ + prematureCloseParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand); + } + /** * @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post. * @returns {Observable} @@ -314,6 +384,39 @@ export class OrganizationService { return this.http.post('/dds/partytransactions', transaction); } + /** + * What an agent wrote down on their round for a date, with what it adds up to. + * + * The counter reads it to count cash against; the agent's own screen shows it back as the record + * of their round. One call for both, so the two can never disagree about the same day. + */ + getAgentCollectionDay(agentId: any, entryDate: string): Observable { + return this.http.get(`/dds/collectionentries?agentId=${agentId}&entryDate=${entryDate}`); + } + + /** + * Turns an agent's outstanding entries for a date into real credits - all of them, together. + * + * No amount is sent. What settles is what the entries say, to the rupee: a figure typed at the + * counter would be a second opinion about a total the entries already give. + */ + settleAgentCollections(settlement: any): Observable { + return this.http.post('/dds/collectionentries/settle', settlement); + } + + /** + * An agent's DDS transactions. The daily entry screen uses this to show what has already been + * keyed for the agent today, so the same collection book is not entered twice. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} That agent's transactions. + */ + getPartyTransactionsOfAgent(agentId: string | number): Observable { + return this.http.get('/dds/partytransactions', { + params: new HttpParams().set('agentId', String(agentId)) + }); + } + /** * @returns {Observable} DDS Commission Slabs data */ @@ -329,6 +432,18 @@ export class OrganizationService { return this.http.post('/dds/commissionslabs', { slabs }); } + /** + * Corrects one saved commission slab. The backend re-checks the corrected range against the other + * slabs of its type and refuses an overlap. + * + * @param {string | number} slabId Commission slab ID. + * @param {any} slab The corrected slab. + * @returns {Observable} Command result. + */ + updateCommissionSlab(slabId: string | number, slab: any): Observable { + return this.http.put(`/dds/commissionslabs/${slabId}`, slab); + } + /** * @param {any} batch Batch run filters (jobType, branchScope, jobDate, agentFrom/To, partyFrom/To) to generate a preview for. * @returns {Observable} @@ -345,6 +460,24 @@ export class OrganizationService { return this.http.get(`/dds/batchruns/${batchRunId}`); } + /** + * @returns {Observable} Every batch run, most recent first — who ran what, and whether it was posted. + */ + getDdsBatchRuns(): Observable { + return this.http.get('/dds/batchruns'); + } + + /** + * Ends the relationship with an agent. Refused while they still have accounts assigned, hold + * collected cash, or are owed commission — distinct from deactivating, which is never refused. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} + */ + closeAgent(agentId: string | number): Observable { + return this.http.post(`/dds/agents/${agentId}`, {}); + } + /** * @param {string | number} batchRunId Batch run ID to post - applies exactly the rows it previewed. * @returns {Observable} diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html deleted file mode 100644 index aeeca63ce..000000000 --- a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.html +++ /dev/null @@ -1,131 +0,0 @@ - - -
-
- -

{{ 'labels.heading.Create Daily Deposit Scheme Product' | translate }}

-
- - - - - - - - - {{ 'labels.inputs.DETAILS' | translate }} - -
- -
-
- - - - - CBS Base Chart - -
- @if (activeBaseVersion) { -
-
-
-

{{ activeBaseVersion.versionLabel }}

-

- Active CBS base chart - Effective from {{ activeBaseVersion.effectiveFromDate }} -

-
- Active -
- - @if (activeBaseVersion.remarks) { -

{{ activeBaseVersion.remarks }}

- } - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? 'N/A' }}Period Type{{ slab.periodTypeLabel }}Annual Rate (%) - {{ slab.annualInterestRate | number: '1.2-2' }} -
-
- } @else { -

Loading CBS base chart...

- } -
-
-
- -
- -
- -
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss deleted file mode 100644 index 6f9abbbfd..000000000 --- a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.scss +++ /dev/null @@ -1,138 +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/. - */ - -$primary: #0f766e; - -.rdp-container { - max-width: 960px; - margin: 0 auto; - padding: 24px 16px; -} - -.rdp-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 20px; - - &__icon { - font-size: 20px; - color: $primary; - } - - &__title { - margin: 0; - font-size: 22px; - font-weight: 500; - } -} - -.rdp-tabs { - background: var(--md-sys-color-surface, #fff); - border-radius: 8px; - box-shadow: 0 2px 8px rgb(0 0 0 / 8%); - - ::ng-deep { - .mat-mdc-tab-header { - border-bottom: 1px solid rgb(0 0 0 / 8%); - } - - .mat-mdc-tab { - min-width: 100px; - padding: 0 12px; - height: 52px; - } - - .mat-mdc-tab-body-wrapper { - min-height: 200px; - } - - .mat-mdc-tab-body { - overflow: visible !important; - } - - .mat-mdc-tab-body-content { - overflow: visible !important; - } - } -} - -.rdp-tab-icon { - margin-right: 8px; - font-size: 14px; -} - -.rdp-tab-content { - padding: 24px 16px; -} - -.base-rate-card { - padding: 16px; - border: 1px solid #dbe4f0; - border-radius: 14px; - background: linear-gradient(180deg, #f8fbff 0%, #fff 100%); -} - -.base-rate-header { - display: flex; - align-items: start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.base-rate-title { - margin: 0; - font-size: 18px; - font-weight: 600; -} - -.base-rate-meta { - margin: 6px 0 0; - color: #5b6b7f; - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.base-rate-badge { - padding: 4px 10px; - border-radius: 999px; - background: #dcfce7; - color: #166534; - font-size: 12px; - font-weight: 700; -} - -.base-rate-remarks { - margin: 0 0 12px; - color: #334155; -} - -.base-rate-table { - width: 100%; - border-collapse: collapse; -} - -.rdp-action-bar { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 20px; - padding: 16px 0; - border-top: 1px solid rgb(0 0 0 / 8%); - - &__cancel { - color: rgb(0 0 0 / 60%); - } - - &__right { - display: flex; - gap: 12px; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts deleted file mode 100644 index 741b1425a..000000000 --- a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component.ts +++ /dev/null @@ -1,346 +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 { AfterViewInit, Component, ViewChild, inject } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - -/** Custom Components — Daily Deposit Scheme's own stepper, kept independent - * of Recurring Deposit Products so future changes to either never cross over. */ -import { DailyDepositSchemeProductDetailsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component'; -import { DailyDepositSchemeProductCurrencyStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component'; -import { DailyDepositSchemeProductTermsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component'; -import { DailyDepositSchemeProductSettingsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component'; -import { DailyDepositSchemeProductChargesStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component'; -import { DailyDepositSchemeProductAccountingStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component'; -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; - -/** Custom Services */ -import { ProductsService } from 'app/products/products.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Accounting } from 'app/core/utils/accounting'; -import { Dates } from 'app/core/utils/dates'; -import { MatTabsModule } from '@angular/material/tabs'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-create-daily-deposit-scheme-product', - templateUrl: './create-daily-deposit-scheme-product.component.html', - styleUrls: ['./create-daily-deposit-scheme-product.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTabsModule, - DailyDepositSchemeProductDetailsStepComponent, - DailyDepositSchemeProductCurrencyStepComponent, - DailyDepositSchemeProductTermsStepComponent, - DailyDepositSchemeProductSettingsStepComponent, - DailyDepositSchemeProductChargesStepComponent, - DailyDepositSchemeProductAccountingStepComponent, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow - ] -}) -export class CreateDailyDepositSchemeProductComponent implements AfterViewInit { - private route = inject(ActivatedRoute); - private productsService = inject(ProductsService); - private router = inject(Router); - private settingsService = inject(SettingsService); - private accounting = inject(Accounting); - private dateUtils = inject(Dates); - - @ViewChild(DailyDepositSchemeProductDetailsStepComponent, { static: true }) - detailsStep: DailyDepositSchemeProductDetailsStepComponent; - @ViewChild(DailyDepositSchemeProductCurrencyStepComponent, { static: true }) - currencyStep: DailyDepositSchemeProductCurrencyStepComponent; - @ViewChild(DailyDepositSchemeProductTermsStepComponent, { static: true }) - termsStep: DailyDepositSchemeProductTermsStepComponent; - @ViewChild(DailyDepositSchemeProductSettingsStepComponent, { static: true }) - settingsStep: DailyDepositSchemeProductSettingsStepComponent; - @ViewChild(DailyDepositSchemeProductChargesStepComponent, { static: true }) - chargesStep: DailyDepositSchemeProductChargesStepComponent; - @ViewChild(DailyDepositSchemeProductAccountingStepComponent, { static: true }) - accountingStep: DailyDepositSchemeProductAccountingStepComponent; - - productsTemplate: any; - accountingRuleData: string[] = []; - selectedTabIndex = 0; - rdInterestRates: any[] = []; - readonly rdSlabColumns = [ - 'srNo', - 'description', - 'fromPeriod', - 'toPeriod', - 'periodTypeLabel', - 'annualInterestRate' - ]; - - constructor() { - this.route.data.subscribe((data: { dailyDepositSchemeProductsTemplate: any }) => { - this.productsTemplate = data.dailyDepositSchemeProductsTemplate; - }); - this.accountingRuleData = this.accounting.getAccountingRulesForSavings(); - this.productsService.getFdBaseInterestRates().subscribe({ - next: (rates: any[]) => { - this.rdInterestRates = rates || []; - }, - error: () => { - this.rdInterestRates = []; - } - }); - } - - ngAfterViewInit(): void { - // The DDS product screen only shows the legacy Account Type Creation fields (Details tab) - - // currency/terms/settings/charges/accounting still exist underneath (the account-creation - // engine needs them), but they're filled in here with fixed sensible defaults rather than - // asked of the user, since the legacy screen never asked for them either. - const template = this.productsTemplate || {}; - - this.currencyForm.patchValue({ - currencyCode: this.currencyForm.value.currencyCode || 'INR', - digitsAfterDecimal: this.currencyForm.value.digitsAfterDecimal ?? 2 - }); - - const firstOptionId = (options: any[]) => options?.[0]?.id ?? ''; - this.termsForm.patchValue({ - interestCompoundingPeriodType: firstOptionId(template.interestCompoundingPeriodTypeOptions), - interestCalculationDaysInYearType: firstOptionId(template.interestCalculationDaysInYearTypeOptions), - minDepositAmount: this.termsForm.value.minDepositAmount || 1, - maxDepositAmount: this.termsForm.value.maxDepositAmount || 999999999 - }); - - // accountingRule 1 = None, matching the legacy screen (which never asked for GL mappings) - - // this also clears every accounting-tab required-field validator, since those only apply - // under Cash/Accrual accounting. - this.accountingForm.patchValue({ accountingRule: 1 }); - - this.detailsForm.get('autoInterestPost')?.valueChanges.subscribe(() => this.syncAutoInterestPost()); - this.detailsForm.get('autoInterestPostWhen')?.valueChanges.subscribe(() => this.syncAutoInterestPost()); - this.syncAutoInterestPost(); - } - - /** Mirrors the simple "Auto Interest Post" / "Auto Interest Post When" / "Auto Interest Post - * Day" fields onto the (hidden) terms-step fields that the account-creation engine actually - * reads. */ - private syncAutoInterestPost(): void { - const autoPost = this.detailsForm?.value.autoInterestPost ?? true; - const postingPeriodType = this.detailsForm?.value.autoInterestPostWhen; - const postingDay = this.detailsForm?.value.autoInterestPostDay; - this.termsForm?.patchValue({ - autoPost, - interestPostingPeriodType: postingPeriodType || this.termsForm.value.interestPostingPeriodType, - frequencyFixDay: postingDay || this.termsForm.value.frequencyFixDay - }); - } - - get detailsForm() { - return this.detailsStep?.DailyDepositSchemeProductDetailsForm; - } - - get currencyForm() { - return this.currencyStep?.DailyDepositSchemeProductCurrencyForm; - } - - get termsForm() { - return this.termsStep?.DailyDepositSchemeProductTermsForm; - } - - get settingsForm() { - return this.settingsStep?.DailyDepositSchemeProductSettingsForm; - } - - get accountingForm() { - return this.accountingStep?.DailyDepositSchemeProductAccountingForm; - } - - get productFormValid() { - return !!this.detailsForm?.valid; - } - - get activeBaseVersion() { - const chart = - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ?? - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ?? - this.rdInterestRates[0]; - if (chart) { - return { - ...chart, - versionLabel: chart.versionLabel?.replace('FD', 'DDS'), - remarks: chart.remarks?.replace('FD', 'DDS') - }; - } - return null; - } - - get activeBaseSlabs() { - // The base chart is borrowed from an FD product purely for its period/amount-range - // *structure* - the account-creation engine needs some chart to exist. Its actual rate is - // never used: every slab's annualInterestRate is overridden with the Details tab's own - // Interest Rate field, so DDS never depends on FD's real interest figures. - const rate = this.detailsForm?.value.interestRate; - return (this.activeBaseVersion?.slabs || []).map((slab: any) => ({ - ...slab, - annualInterestRate: rate ?? slab.annualInterestRate - })); - } - - get product() { - // accountTypeGroup / autoInterestPost / autoInterestPostWhen / autoInterestPostDay are the - // legacy Account Type Creation screen's own fields - not real product API parameters. The - // auto-interest-post trio is already mirrored onto termsStep (autoPost / - // interestPostingPeriodType / frequencyFixDay) via syncAutoInterestPost(); accountTypeGroup - // is folded into the description here instead. - const { - accountTypeGroup, - autoInterestPost, - autoInterestPostWhen, - autoInterestPostDay, - interestRate, - ...detailsPayload - } = this.detailsStep.DailyDepositSchemeProductDetails; - const description = [ - accountTypeGroup, - detailsPayload.description - ] - .filter((part) => !!part) - .join(' - '); - - return { - ...detailsPayload, - description, - ...this.currencyStep.DailyDepositSchemeProductCurrency, - ...this.termsStep.DailyDepositSchemeProductTerms, - ...this.settingsStep.DailyDepositSchemeProductSettings, - ...this.chargesStep.DailyDepositSchemeProductCharges, - ...this.accountingStep.DailyDepositSchemeProductAccounting, - charts: this.productCharts, - rdSlabs: this.activeBaseSlabs - }; - } - - get productCharts() { - const activeBaseVersion = this.activeBaseVersion; - if (!activeBaseVersion?.slabs?.length) { - return []; - } - const dateFormat = this.settingsService.dateFormat; - - return [ - { - name: activeBaseVersion.versionLabel || 'CBS Base Chart', - description: activeBaseVersion.remarks || activeBaseVersion.versionLabel || 'CBS base chart', - fromDate: - this.dateUtils.formatDate(activeBaseVersion.effectiveFromDate, dateFormat) || - activeBaseVersion.effectiveFromDate, - ...(activeBaseVersion.effectiveToDate - ? { - endDate: - this.dateUtils.formatDate(activeBaseVersion.effectiveToDate, dateFormat) || - activeBaseVersion.effectiveToDate - } - : {}), - isPrimaryGroupingByAmount: false, - locale: this.settingsService.language.code, - dateFormat, - chartSlabs: this.activeBaseSlabs.map((slab: any) => { - const chartSlab: any = { - periodType: slab.periodTypeEnum, - fromPeriod: slab.fromPeriod, - toPeriod: slab.toPeriod, - annualInterestRate: slab.annualInterestRate, - description: slab.description || '', - incentives: [] as any[] - }; - - if (slab.amountRangeFrom !== null && slab.amountRangeFrom !== undefined && slab.amountRangeFrom !== '') { - chartSlab.amountRangeFrom = slab.amountRangeFrom; - } - - if (slab.amountRangeTo !== null && slab.amountRangeTo !== undefined && slab.amountRangeTo !== '') { - chartSlab.amountRangeTo = slab.amountRangeTo; - } - - return chartSlab; - }) - } - ] as any[]; - } - - submit() { - const product: any = this.pruneEmptyValues({ - ...this.product, - charts: this.productCharts, - charges: (this.product.charges ?? []).map((charge: any) => ({ id: charge.id })), - locale: this.settingsService.language.code, - productCategory: 'DDS' - }); - if (!product.description) { - product.description = ''; - } - delete product.advancedAccountingRules; - this.productsService.createRecurringDepositProduct(product).subscribe((response: any) => { - this.router.navigate( - [ - '../', - response.resourceId - ], - { relativeTo: this.route } - ); - }); - } - - private pruneEmptyValues(value: T): T { - if (Array.isArray(value)) { - return value.map((item) => this.pruneEmptyValues(item)).filter((item) => item !== undefined) as T; - } - - if (value && typeof value === 'object') { - return Object.entries(value as Record).reduce( - (acc, [ - key, - currentValue - ]) => { - const cleanedValue = this.pruneEmptyValues(currentValue); - if (cleanedValue === undefined) { - return acc; - } - return { - ...acc, - [key]: cleanedValue - }; - }, - {} as Record - ) as T; - } - - if (value === '' || value === null || value === undefined) { - return undefined as T; - } - - return value; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts b/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts deleted file mode 100644 index f70da1eda..000000000 --- a/src/app/products/daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver.ts +++ /dev/null @@ -1,35 +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 { Injectable, inject } from '@angular/core'; - -/** rxjs Imports */ -import { Observable } from 'rxjs'; - -/** Custom Services */ -import { ProductsService } from '../../products.service'; - -/** - * Daily Deposit Scheme Products template resolver. - * - * Reuses the Recurring Deposit product template — a Daily Deposit Scheme - * product is created through the same underlying product-creation engine. - */ -@Injectable() -export class DailyDepositSchemeProductsTemplateResolver { - private productsService = inject(ProductsService); - - /** - * Returns the Daily Deposit Scheme products template. - * @returns {Observable} - */ - resolve(): Observable { - return this.productsService.getRecurringDepositProductsTemplate(); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html deleted file mode 100644 index f7fd053a9..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.html +++ /dev/null @@ -1,121 +0,0 @@ - - -
-
-
- - - -

Accounting

-
- -
-
-
-

Accounting Rule

-
-
- Cash -
-
- - @if (isCashOrAccrualAccounting()) { - @for (section of accountingSectionConfigs; track section.key) { -
-
-

{{ section.title }}

- -
-
- @for (row of section.rows; track row.key) { - @if ( - (!row.accrualOnly || isAccrualAccounting()) && (!row.dormancyOnly || isDormancyTrackingEnabled()) - ) { -
-
- - {{ section.pillLabel }} - - {{ row.label }} -
- -
- } - } -
- -
- @for ( - otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls; - track $index; - let i = $index - ) { -
-
-
- - {{ section.pillLabel }} - - -
- -
- - GL Code - - @for (glAccount of section.otherAccountOptions; track glAccount.id) { - - {{ '(' + glAccount.glCode + ') ' + glAccount.name }} - - } - - -
-
- -
- } -
-
- } - } -
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss deleted file mode 100644 index bac63a1a2..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.scss +++ /dev/null @@ -1,303 +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/. - */ - -$border: #e2e8f0; -$gray-50: #f8f9fa; -$gray-200: #e8eaed; -$gray-700: #334155; -$gray-900: #202124; - -.rdpa-section { - border: 1px solid $border; - border-radius: 10px; - background: #fff; -} - -.rdpa-section__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: $gray-50; - border-bottom: 1px solid $gray-200; - - h3 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } -} - -.rdpa-section__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 6px; - background: #eff6ff; - color: #2563eb; - font-size: 13px; -} - -.rdpa-section__body { - padding: 16px 24px 20px; -} - -.rdpa-group { - & + & { - margin-top: 20px; - } - - &__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin: 0 0 12px; - } - - h4 { - margin: 0 0 12px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #94a3b8; - } -} - -.rdpa-add-btn { - border: 0; - background: transparent; - color: #2563eb; - font-size: 12px; - font-weight: 700; - padding: 0; - cursor: pointer; -} - -.rdpa-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.rdpa-list + .rdpa-list { - margin-top: 12px; -} - -.rdpa-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 10px 12px; - border: 1px solid $border; - border-radius: 8px; - background: #fff; - - @media (width <= 900px) { - flex-direction: column; - align-items: stretch; - } -} - -.rdpa-row:has(mifosx-gl-account-selector) { - align-items: stretch; -} - -.rdpa-row--other { - border-style: dashed; - border-color: #cbd5e1; - background: #f8fafc; - display: grid; - grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); - align-items: center; - padding: 12px 16px; - - @media (width <= 1100px) { - grid-template-columns: 1fr; - } -} - -.rdpa-other-row-wrap { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - - @media (width <= 1100px) { - grid-template-columns: 1fr; - gap: 8px; - } -} - -.rdpa-row__meta { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - flex: 1 1 auto; -} - -.rdpa-row__meta--other { - min-width: 0; - flex: 1 1 auto; - align-self: stretch; -} - -.rdpa-row__label { - font-size: 14px; - font-weight: 600; - color: $gray-700; -} - -.rdpa-row__input { - flex: 0 0 22rem; - max-width: 22rem; - min-width: 18rem; - - @media (width <= 900px) { - width: 100%; - max-width: 100%; - min-width: 0; - } -} - -.rdpa-row__input :is(mifosx-gl-account-selector) { - width: 100%; - display: block; -} - -.rdpa-row__input :is(mifosx-gl-account-selector) :is(.layout-column) { - width: 100%; - margin-right: 0 !important; -} - -.rdpa-row__input :is(mifosx-gl-account-selector) :is(mat-form-field) { - width: 100%; -} - -.rdpa-row__actions { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.rdpa-row__actions--other { - justify-content: flex-end; - align-self: stretch; - width: 100%; - - @media (width <= 1100px) { - justify-content: flex-start; - } -} - -.rdpa-row__name { - width: 100%; - min-width: 0; - max-width: 24rem; - height: 40px; - border: 1px solid #cbd5e1; - border-radius: 6px; - padding: 0 12px; - font-size: 14px; - background: #fff; - - @media (width <= 900px) { - max-width: 100%; - } -} - -.rdpa-row__name--other-account { - max-width: 100%; - flex: 1 1 auto; - height: 48px; -} - -.rdpa-row__other-field { - width: 100%; - min-width: 0; -} - -.rdpa-row__other-field :is(mat-form-field) { - width: 100%; -} - -.rdpa-row__other-field :is(.mat-mdc-form-field-required-marker) { - display: none; -} - -.rdpa-remove-btn { - border: 0; - background: transparent; - color: #ef4444; - font-size: 18px; - line-height: 1; - cursor: pointer; - padding: 0 4px; -} - -.rdpa-remove-btn--side { - place-self: center end; - margin-right: 4px; - - @media (width <= 1100px) { - justify-self: start; - margin-right: 0; - } -} - -.rdpa-radio-group { - display: flex; - gap: 24px; - flex-wrap: wrap; -} - -.rdpa-pill { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - white-space: nowrap; - - &--green { - background: #dcfce7; - color: #16a34a; - } - - &--amber { - background: #fef3c7; - color: #d97706; - } - - &--blue { - background: #dbeafe; - color: #2563eb; - } -} - -@media (width <= 900px) { - .rdpa-row__meta { - margin-bottom: 4px; - } - - .rdpa-row__input { - flex: 1 1 auto; - max-width: 100%; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts deleted file mode 100644 index 09ef9c1d7..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component.ts +++ /dev/null @@ -1,423 +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/. - */ - -import { Component, Input, OnInit, inject } from '@angular/core'; -import { - FormsModule, - ReactiveFormsModule, - UntypedFormArray, - UntypedFormBuilder, - UntypedFormControl, - UntypedFormGroup, - Validators -} from '@angular/forms'; -import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; -import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component'; -import { Accounting } from 'app/core/utils/accounting'; -import { GLAccount } from 'app/shared/models/general.model'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income'; - -type AccountingRowConfig = { - key: string; - label: string; - inputLabel: string; - controlName: string; - options: GLAccount[]; - required?: boolean; - accrualOnly?: boolean; - dormancyOnly?: boolean; -}; - -type AccountingSectionConfig = { - key: AccountingSectionKey; - title: string; - pillLabel: string; - pillClass: 'green' | 'amber' | 'blue'; - rows: AccountingRowConfig[]; - otherMappingsControlName: string; - otherAccountOptions: GLAccount[]; -}; - -type OtherGlMappingValue = { - accountName: string; - glAccountId: number | null; -}; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-accounting-step', - templateUrl: './daily-deposit-scheme-product-accounting-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-accounting-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - FormsModule, - ReactiveFormsModule, - MatRadioGroup, - MatRadioButton, - GlAccountSelectorComponent - ] -}) -export class DailyDepositSchemeProductAccountingStepComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - private accounting = inject(Accounting); - - @Input() DailyDepositSchemeProductsTemplate: any; - @Input() accountingRuleData: any; - @Input() DailyDepositSchemeProductFormValid: boolean; - @Input() isDormancyTrackingActive: any; - - DailyDepositSchemeProductAccountingForm: UntypedFormGroup; - assetAccountOptions: GLAccount[] = []; - liabilityAccountOptions: GLAccount[] = []; - expenseAccountOptions: GLAccount[] = []; - incomeAccountOptions: GLAccount[] = []; - accountingSectionConfigs: AccountingSectionConfig[] = []; - - constructor() { - this.createDailyDepositSchemeProductAccountingForm(); - this.setConditionalControls(); - } - - ngOnInit() { - const accountingMappings = this.DailyDepositSchemeProductsTemplate?.accountingMappings || {}; - const mappings = { - ...accountingMappings, - otherGlMappingsMap: this.DailyDepositSchemeProductsTemplate?.otherGlMappingsMap - }; - const accountingMappingOptions = this.DailyDepositSchemeProductsTemplate?.accountingMappingOptions || {}; - // Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves - // the product's own current accounting rule when editing - previously this was hardcoded to 2 - // unconditionally, which silently forced every edited product onto Cash-based accounting (and - // its required GL mappings) even for products that were and should remain "None". - const accountingRuleId = this.DailyDepositSchemeProductsTemplate?.accountingRule?.id ?? 2; - - this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || []; - this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; - this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; - this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; - - this.buildAccountingSectionConfigs(); - - this.DailyDepositSchemeProductAccountingForm.patchValue({ accountingRule: accountingRuleId }); - this.syncAccountingControls(accountingRuleId); - this.patchAccountingValues(mappings); - this.setDormancyControl(); - } - - createDailyDepositSchemeProductAccountingForm() { - this.DailyDepositSchemeProductAccountingForm = this.formBuilder.group({ - accountingRule: [2], - savingsReferenceAccountId: [''], - savingsControlAccountId: [''], - transfersInSuspenseAccountId: [''], - interestOnSavingsAccountId: [''], - incomeFromFeeAccountId: [''], - incomeFromPenaltyAccountId: [''], - feesReceivableAccountId: [''], - penaltiesReceivableAccountId: [''], - interestPayableAccountId: [''], - escheatLiabilityId: [''], - assetsOtherGlMappings: this.formBuilder.array([]), - liabilitiesOtherGlMappings: this.formBuilder.array([]), - expensesOtherGlMappings: this.formBuilder.array([]), - incomeOtherGlMappings: this.formBuilder.array([]) - }); - } - - private setConditionalControls() { - this.DailyDepositSchemeProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((rule: number) => { - this.syncAccountingControls(rule); - }); - } - - private patchAccountingValues(mappings: any) { - this.DailyDepositSchemeProductAccountingForm.patchValue({ - savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '', - savingsControlAccountId: mappings.savingsControlAccount?.id || '', - transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '', - interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '', - incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '', - incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '', - feesReceivableAccountId: mappings.feeReceivableAccount?.id || '', - penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', - interestPayableAccountId: mappings.interestPayableAccount?.id || '', - escheatLiabilityId: mappings.escheatLiabilityAccount?.id || '' - }); - - if (this.isCashOrAccrualAccounting()) { - const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings; - this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions); - this.setOtherGlMappings( - 'liabilitiesOtherGlMappings', - glMappings?.liabilities || [], - this.liabilityAccountOptions - ); - this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions); - this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions); - } - } - - private createOtherGlMappingGroup(otherGlMapping: Partial = {}) { - return this.formBuilder.group({ - accountName: [ - otherGlMapping.accountName || '', - Validators.required - ], - glAccountId: [ - otherGlMapping.glAccountId || '', - Validators.required - ] - }); - } - - getOtherGlMappingsArray(controlName: string): UntypedFormArray { - return this.DailyDepositSchemeProductAccountingForm.get(controlName) as UntypedFormArray; - } - - private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) { - const arr = this.getOtherGlMappingsArray(controlName); - arr.clear(); - (otherGlMappings || []).forEach((m) => { - arr.push( - this.createOtherGlMappingGroup({ - accountName: m.accountName, - glAccountId: m.glAccountId || options.find((o) => o.glCode === m.glCode)?.id || null - }) - ); - }); - } - - addOtherGlMapping(controlName: string) { - this.getOtherGlMappingsArray(controlName).push(this.createOtherGlMappingGroup()); - } - - removeOtherGlMapping(controlName: string, index: number) { - this.getOtherGlMappingsArray(controlName).removeAt(index); - } - - getOtherGlMappingControl(controlName: string, index: number, fieldName: string): UntypedFormControl { - return this.getOtherGlMappingsArray(controlName).at(index).get(fieldName) as UntypedFormControl; - } - - private clearOtherGlMappings() { - [ - 'assetsOtherGlMappings', - 'liabilitiesOtherGlMappings', - 'expensesOtherGlMappings', - 'incomeOtherGlMappings' - ].forEach((c) => this.getOtherGlMappingsArray(c).clear()); - } - - private getOtherGlMappings() { - const map = (controlName: string, options: GLAccount[]) => - this.getOtherGlMappingsArray(controlName) - .value.filter((item: OtherGlMappingValue) => !!item.accountName || !!item.glAccountId) - .map((item: OtherGlMappingValue) => ({ - accountName: item.accountName, - glCode: options.find((o) => o.id === item.glAccountId)?.glCode || '' - })) - .filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode); - - const otherGlMappings = { - assets: map('assetsOtherGlMappings', this.assetAccountOptions), - liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), - expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), - income: map('incomeOtherGlMappings', this.incomeAccountOptions) - }; - return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; - } - - private setDormancyControl() { - this.isDormancyTrackingActive?.valueChanges?.subscribe(() => { - this.syncAccountingControls(this.DailyDepositSchemeProductAccountingForm.value.accountingRule); - }); - } - - isDormancyTrackingEnabled(): boolean { - return this.isDormancyTrackingActive?.value ?? this.isDormancyTrackingActive ?? false; - } - - private syncAccountingControls(accountingRule: number) { - const setRequired = (name: string, required: boolean) => { - const ctrl = this.DailyDepositSchemeProductAccountingForm.get(name); - if (!ctrl) return; - if (required) { - ctrl.setValidators(Validators.required); - } else { - ctrl.clearValidators(); - } - ctrl.updateValueAndValidity({ emitEvent: false }); - }; - - const cashOrAccrual = this.accounting.isCashOrAccrualAccountingRuleId(accountingRule); - const accrualOnly = this.accounting.isAccrualAccountingRuleId(accountingRule); - - setRequired('savingsReferenceAccountId', cashOrAccrual); - setRequired('savingsControlAccountId', cashOrAccrual); - setRequired('transfersInSuspenseAccountId', cashOrAccrual); - setRequired('interestOnSavingsAccountId', cashOrAccrual); - setRequired('incomeFromFeeAccountId', cashOrAccrual); - setRequired('incomeFromPenaltyAccountId', cashOrAccrual); - setRequired('feesReceivableAccountId', accrualOnly); - setRequired('penaltiesReceivableAccountId', accrualOnly); - setRequired('interestPayableAccountId', accrualOnly); - setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); - - if (!cashOrAccrual) { - this.clearOtherGlMappings(); - } - } - - buildAccountingSectionConfigs() { - this.accountingSectionConfigs = [ - { - key: 'assets', - title: 'Assets', - pillLabel: 'Asset', - pillClass: 'green', - rows: [ - { - key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', - controlName: 'savingsReferenceAccountId', - options: this.assetAccountOptions, - required: true - }, - { - key: 'feesReceivableAccountId', - label: 'Fees Receivable Account', - inputLabel: 'Fees receivable account', - controlName: 'feesReceivableAccountId', - options: this.assetAccountOptions, - accrualOnly: true - }, - { - key: 'penaltiesReceivableAccountId', - label: 'Penalties Receivable Account', - inputLabel: 'Penalties receivable account', - controlName: 'penaltiesReceivableAccountId', - options: this.assetAccountOptions, - accrualOnly: true - } - ], - otherMappingsControlName: 'assetsOtherGlMappings', - otherAccountOptions: this.assetAccountOptions - }, - { - key: 'liabilities', - title: 'Liabilities', - pillLabel: 'Liability', - pillClass: 'green', - rows: [ - { - key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', - controlName: 'savingsControlAccountId', - options: this.liabilityAccountOptions, - required: true - }, - { - key: 'transfersInSuspenseAccountId', - label: 'Transfers In Suspense Account', - inputLabel: 'Transfers in suspense account', - controlName: 'transfersInSuspenseAccountId', - options: this.liabilityAccountOptions, - required: true - }, - { - key: 'interestPayableAccountId', - label: 'Interest Payable Account', - inputLabel: 'Interest payable account', - controlName: 'interestPayableAccountId', - options: this.liabilityAccountOptions, - accrualOnly: true - }, - { - key: 'escheatLiabilityId', - label: 'Escheat Liability Account', - inputLabel: 'Escheat liability account', - controlName: 'escheatLiabilityId', - options: this.liabilityAccountOptions, - dormancyOnly: true - } - ], - otherMappingsControlName: 'liabilitiesOtherGlMappings', - otherAccountOptions: this.liabilityAccountOptions - }, - { - key: 'expenses', - title: 'Expenses', - pillLabel: 'Expense', - pillClass: 'amber', - rows: [ - { - key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', - controlName: 'interestOnSavingsAccountId', - options: this.expenseAccountOptions, - required: true - } - ], - otherMappingsControlName: 'expensesOtherGlMappings', - otherAccountOptions: this.expenseAccountOptions - }, - { - key: 'income', - title: 'Income', - pillLabel: 'Income', - pillClass: 'blue', - rows: [ - { - key: 'incomeFromFeeAccountId', - label: 'Income From Fees', - inputLabel: 'Income from fees', - controlName: 'incomeFromFeeAccountId', - options: this.incomeAccountOptions, - required: true - }, - { - key: 'incomeFromPenaltyAccountId', - label: 'Income From Penalties', - inputLabel: 'Income from penalties', - controlName: 'incomeFromPenaltyAccountId', - options: this.incomeAccountOptions, - required: true - } - ], - otherMappingsControlName: 'incomeOtherGlMappings', - otherAccountOptions: this.incomeAccountOptions - } - ]; - } - - get DailyDepositSchemeProductAccounting() { - const { - assetsOtherGlMappings, - liabilitiesOtherGlMappings, - expensesOtherGlMappings, - incomeOtherGlMappings, - ...accounting - } = this.DailyDepositSchemeProductAccountingForm.value; - const otherGlMappings = this.getOtherGlMappings(); - return { ...accounting, ...(otherGlMappings ? { otherGlMappings } : {}) }; - } - - isCashOrAccrualAccounting(): boolean { - return this.accounting.isCashOrAccrualAccountingRuleId( - this.DailyDepositSchemeProductAccountingForm.value.accountingRule - ); - } - - isAccrualAccounting(): boolean { - return this.accounting.isAccrualAccountingRuleId(this.DailyDepositSchemeProductAccountingForm.value.accountingRule); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html deleted file mode 100644 index b2c701878..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.html +++ /dev/null @@ -1,85 +0,0 @@ - - -
-
- -

{{ 'labels.heading.Charges' | translate }}

-
-
-
- - {{ 'labels.inputs.Charge' | translate }} - - @for (charge of chargeData | chargesFilter: chargesDataSource : currencyCode.value; track charge) { - - {{ charge.name }} - - } - - - -
- - @if (chargesDataSource.length > 0) { -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }} - {{ charge.name + ', ' + charge.currency.displaySymbol }} - {{ 'labels.inputs.Type' | translate }} - {{ charge.chargeCalculationType.value }} - {{ 'labels.inputs.Amount' | translate }} - {{ charge.amount }} - {{ 'labels.inputs.Collected On' | translate }} - {{ charge.chargeTimeType.value }} - {{ 'labels.inputs.Actions' | translate }} - -
-
- } -
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss deleted file mode 100644 index 661815e4c..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.scss +++ /dev/null @@ -1,69 +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/. - */ - -.rdpch-section { - border: 1px solid #e2e8f0; - border-radius: 10px; - overflow: hidden; - background: #fff; - - &__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: #f8f9fa; - border-bottom: 1px solid #e8eaed; - - i { - font-size: 14px; - color: #0ea5e9; - } - - h3 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: #202124; - } - } - - &__body { - padding: 16px 24px; - } -} - -.rdpch-add-row { - display: flex; - align-items: center; - gap: 16px; - flex-wrap: wrap; - - &__select { - flex: 1; - min-width: 200px; - } -} - -.rdpch-add-btn { - height: 36px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; -} - -.rdpch-table-wrapper { - border: 1px solid #e2e8f0; - border-radius: 8px; - overflow: hidden; - margin-top: 12px; - - table { - width: 100%; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts deleted file mode 100644 index 4db6f9ae9..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component.ts +++ /dev/null @@ -1,103 +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/. - */ - -import { Component, Input, OnInit, inject } from '@angular/core'; -import { UntypedFormControl } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; - -import { TranslateService } from '@ngx-translate/core'; -import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; -import { MatButton, MatIconButton } from '@angular/material/button'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; -import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-charges-step', - templateUrl: './daily-deposit-scheme-product-charges-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-charges-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatIconButton, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - ChargesFilterPipe - ] -}) -export class DailyDepositSchemeProductChargesStepComponent implements OnInit { - dialog = inject(MatDialog); - private translateService = inject(TranslateService); - - @Input() DailyDepositSchemeProductsTemplate: any; - @Input() currencyCode: UntypedFormControl; - - chargeData: any; - - chargesDataSource: {}[]; - displayedColumns: string[] = [ - 'name', - 'chargeCalculationType', - 'amount', - 'chargeTimeType', - 'action' - ]; - - ngOnInit() { - this.chargeData = this.DailyDepositSchemeProductsTemplate.chargeOptions; - if (!(this.DailyDepositSchemeProductsTemplate === undefined) && this.DailyDepositSchemeProductsTemplate.id) { - this.chargesDataSource = this.DailyDepositSchemeProductsTemplate.charges; - } else { - this.chargesDataSource = []; - } - this.currencyCode.valueChanges.subscribe(() => (this.chargesDataSource = [])); - } - - addCharge(charge: any) { - this.chargesDataSource = this.chargesDataSource.concat([charge.value]); - charge.value = ''; - } - - deleteCharge(charge: any) { - const deleteChargeDialogRef = this.dialog.open(DeleteDialogComponent, { - data: { deleteContext: this.translateService.instant('labels.inputs.Charge') + ' ' + charge.name } - }); - deleteChargeDialogRef.afterClosed().subscribe((response: any) => { - if (response.delete) { - this.chargesDataSource.splice(this.chargesDataSource.indexOf(charge), 1); - this.chargesDataSource = this.chargesDataSource.concat([]); - } - }); - } - - get DailyDepositSchemeProductCharges() { - return { - charges: this.chargesDataSource - }; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html deleted file mode 100644 index 86af4f7ea..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.html +++ /dev/null @@ -1,89 +0,0 @@ - - -
-
-
- - Currency & Account Status Configuration -
-
-
- - {{ 'labels.inputs.Currency' | translate }} - - @for (currency of currencyData; track currency) { - - {{ currency.name }} - - } - - Default: Indian Rupee - auto-set for domestic products - - {{ 'labels.inputs.Currency' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Decimal Places' | translate }} - - @for (places of decimalPlacesData; track places) { - {{ places }} - } - - - {{ 'labels.inputs.Decimal Places' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - -
- -
-
-

Dormancy Alert

-

Enable automated notifications when an account becomes inactive based on product rules.

-
- - yes - no - -
- -
-
-

Nomination Facility

-

Allow account holders to designate individuals to receive funds in the event of account holder's death.

-
- - yes - no - -
- -
-
-

Auto Debit Facility

-

Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.

-
- - yes - no - -
-
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss deleted file mode 100644 index d7a2abd40..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.scss +++ /dev/null @@ -1,102 +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/. - */ - -$accent: #0ea5e9; -$border: #e2e8f0; -$gray-50: #f8f9fa; -$gray-200: #e8eaed; -$gray-900: #202124; - -.rdpc-section { - border: 1px solid $border; - border-radius: 10px; - background: #fff; - - & + & { - margin-top: 16px; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: $gray-50; - border-bottom: 1px solid $gray-200; - - i { - font-size: 14px; - color: $accent; - } - - span { - margin: 0; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } - } -} - -.rdpc-body { - background: #fff; -} - -.rdpc-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 4px 16px; - padding: 16px 24px; - - @media (width <= 600px) { - grid-template-columns: 1fr; - } -} - -.rdpc-field { - width: 100%; -} - -.rdpc-alert { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 18px 24px 24px; - border-top: 1px solid $border; - background: #f8fafc; - - @media (width <= 760px) { - flex-direction: column; - align-items: flex-start; - } - - &__content { - h4 { - margin: 0 0 6px; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } - - p { - margin: 0; - font-size: 12px; - line-height: 1.5; - color: #64748b; - max-width: 540px; - } - } - - &__choices { - display: flex; - align-items: center; - gap: 18px; - flex-wrap: wrap; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts deleted file mode 100644 index 098800d33..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component.ts +++ /dev/null @@ -1,104 +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/. - */ - -import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; -import { MatTooltip } from '@angular/material/tooltip'; -import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-currency-step', - templateUrl: './daily-deposit-scheme-product-currency-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-currency-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTooltip, - MatRadioGroup, - MatRadioButton - ] -}) -export class DailyDepositSchemeProductCurrencyStepComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - - @Input() DailyDepositSchemeProductsTemplate: any; - - DailyDepositSchemeProductCurrencyForm: UntypedFormGroup; - - currencyData: any; - decimalPlacesData = [ - 2, - 0, - 3 - ]; - - constructor() { - this.createDailyDepositSchemeProductCurrencyForm(); - } - - ngOnInit() { - this.currencyData = this.DailyDepositSchemeProductsTemplate.currencyOptions.filter( - (c: { code: string }) => c.code === 'INR' - ); - - this.DailyDepositSchemeProductCurrencyForm.patchValue({ - currencyCode: this.DailyDepositSchemeProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '', - digitsAfterDecimal: - this.DailyDepositSchemeProductsTemplate.digitsAfterDecimal ?? - this.DailyDepositSchemeProductsTemplate.currency?.decimalPlaces ?? - 2, - dormancyAlert: this.DailyDepositSchemeProductsTemplate.dormancyAlert ?? false, - isDormancyTrackingActive: this.DailyDepositSchemeProductsTemplate.isDormancyTrackingActive ?? false, - daysToInactive: this.DailyDepositSchemeProductsTemplate.daysToInactive ?? 30, - daysToDormancy: this.DailyDepositSchemeProductsTemplate.daysToDormancy ?? 60, - daysToEscheat: this.DailyDepositSchemeProductsTemplate.daysToEscheat ?? 90, - nominationFacility: this.DailyDepositSchemeProductsTemplate.nominationFacility ?? false, - autoDebitFacility: this.DailyDepositSchemeProductsTemplate.autoDebitFacility ?? false - }); - } - - createDailyDepositSchemeProductCurrencyForm() { - this.DailyDepositSchemeProductCurrencyForm = this.formBuilder.group({ - currencyCode: [ - '', - Validators.required - ], - digitsAfterDecimal: [ - 2, - [ - Validators.required, - Validators.min(0) - ] - ], - dormancyAlert: [false], - isDormancyTrackingActive: [false], - daysToInactive: [30], - daysToDormancy: [60], - daysToEscheat: [90], - nominationFacility: [false], - autoDebitFacility: [false] - }); - } - - get DailyDepositSchemeProductCurrency() { - const formValue = this.DailyDepositSchemeProductCurrencyForm.value; - const result: any = { - currencyCode: formValue.currencyCode, - digitsAfterDecimal: formValue.digitsAfterDecimal, - dormancyAlert: formValue.dormancyAlert, - nominationFacility: formValue.nominationFacility, - autoDebitFacility: formValue.autoDebitFacility - }; - if (formValue.dormancyAlert) { - result.daysToInactive = formValue.daysToInactive; - result.daysToDormancy = formValue.daysToDormancy; - result.daysToEscheat = formValue.daysToEscheat; - } - return result; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html deleted file mode 100644 index 2fae4cfe6..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.html +++ /dev/null @@ -1,77 +0,0 @@ - - -
-
-
- -

Account Type Creation

-
-
- - Account Type Group - - @if (DailyDepositSchemeProductDetailsForm.controls.accountTypeGroup.hasError('required')) { - Account Type Group is required - } - - - - A/c Type - - @if (DailyDepositSchemeProductDetailsForm.controls.shortName.hasError('required')) { - A/c Type is required - } - - - - A/c Type Description - - @if (DailyDepositSchemeProductDetailsForm.controls.name.hasError('required')) { - A/c Type Description is required - } - - - - Interest Rate (%) - - @if (DailyDepositSchemeProductDetailsForm.controls.interestRate.hasError('required')) { - Interest Rate is required - } - - - - Auto Interest Post - - @for (option of yesNoOptions; track option.value) { - {{ option.label }} - } - - - - @if (DailyDepositSchemeProductDetailsForm.get('autoInterestPost')?.value) { - - Auto Interest Post When - - @for (option of autoInterestPostWhenData; track option.id) { - {{ option.value }} - } - - @if (DailyDepositSchemeProductDetailsForm.controls.autoInterestPostWhen.hasError('required')) { - Auto Interest Post When is required - } - - - - Auto Interest Post Day - - - } -
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss deleted file mode 100644 index ca87b04f4..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.scss +++ /dev/null @@ -1,81 +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/. - */ - -$accent: #0ea5e9; -$border: #e2e8f0; -$gray-50: #f8f9fa; -$gray-200: #e8eaed; -$gray-900: #202124; - -.rdpd-section { - border: 1px solid $border; - border-radius: 10px; - background: #fff; - - & + & { - margin-top: 16px; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: $gray-50; - border-bottom: 1px solid $gray-200; - - i { - font-size: 14px; - color: $accent; - } - - h3 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } - } -} - -.rdpd-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 4px 16px; - padding: 16px 24px; - - @media (width <= 600px) { - grid-template-columns: 1fr; - } -} - -.rdpd-grid--classification { - grid-template-columns: repeat(3, 1fr); - - @media (width <= 900px) { - grid-template-columns: repeat(2, 1fr); - } - - @media (width <= 600px) { - grid-template-columns: 1fr; - } -} - -.rdpd-field { - width: 100%; - - &--full { - grid-column: 1 / -1; - } -} - -.rdpd-checkbox-row { - display: flex; - align-items: center; - min-height: 56px; -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts deleted file mode 100644 index 65f517f15..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component.ts +++ /dev/null @@ -1,177 +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/. - */ - -import { Component, OnInit, OnChanges, Input, SimpleChanges, inject, ChangeDetectorRef } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; -import { MatTooltip } from '@angular/material/tooltip'; -import { CdkTextareaAutosize } from '@angular/cdk/text-field'; -import { Dates } from 'app/core/utils/dates'; -import { SettingsService } from 'app/settings/settings.service'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-details-step', - templateUrl: './daily-deposit-scheme-product-details-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-details-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTooltip, - CdkTextareaAutosize - ] -}) -export class DailyDepositSchemeProductDetailsStepComponent implements OnInit, OnChanges { - private formBuilder = inject(UntypedFormBuilder); - private dateUtils = inject(Dates); - private settingsService = inject(SettingsService); - private cdr = inject(ChangeDetectorRef); - - @Input() DailyDepositSchemeProductsTemplate: any; - - DailyDepositSchemeProductDetailsForm: UntypedFormGroup; - accountTypeData: string[] = []; - customerSegmentData: string[] = []; - liabilityAccountOptions: any[] = []; - /** "Auto Interest Post When" options - the legacy screen's own field, sourced from the same - * enum the (hidden) interest-posting-period setting uses, so the two always stay in sync. */ - autoInterestPostWhenData: any[] = []; - yesNoOptions: Array<{ value: boolean; label: string }> = [ - { value: true, label: 'Yes' }, - { value: false, label: 'No' } - ]; - - constructor() { - this.createDailyDepositSchemeProductDetailsForm(); - } - - ngOnInit() { - if (this.DailyDepositSchemeProductsTemplate) { - this.applyTemplate(this.DailyDepositSchemeProductsTemplate); - } - } - - ngOnChanges(changes: SimpleChanges) { - if (changes.DailyDepositSchemeProductsTemplate?.currentValue) { - this.applyTemplate(changes.DailyDepositSchemeProductsTemplate.currentValue); - } - } - - createDailyDepositSchemeProductDetailsForm() { - this.DailyDepositSchemeProductDetailsForm = this.formBuilder.group({ - accountTypeGroup: [ - '', - Validators.required - ], - name: [ - '', - Validators.required - ], - shortName: [ - '', - Validators.required - ], - description: [''], - autoInterestPost: [true], - autoInterestPostWhen: [ - '', - Validators.required - ], - autoInterestPostDay: [''], - interestRate: [ - 2, - [ - Validators.required, - Validators.min(0) - ] - ], - accountType: [ - null, - Validators.required - ], - glAccountId: [null], - effectiveTo: [''], - endDate: [''], - introducerRequired: [false], - shareLinkingEnabled: [false] - }); - } - - get minEndDate(): Date | null { - const v = this.DailyDepositSchemeProductDetailsForm.get('effectiveTo')?.value; - if (!v) return null; - const d = new Date(v); - d.setDate(d.getDate() + 1); - return d; - } - - private getOptionNames(options: any[]): string[] { - if (!Array.isArray(options)) { - return []; - } - return options - .map((option) => option?.name ?? option?.value ?? option) - .filter((value) => typeof value === 'string' && value.trim().length > 0); - } - - private parseDate(dateValue: any): Date | null { - if (!dateValue) return null; - if (Array.isArray(dateValue)) { - return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); - } - if (dateValue instanceof Date) { - return dateValue; - } - return new Date(dateValue); - } - - compareById(o1: any, o2: any): boolean { - return o1 != null && o2 != null && Number(o1) === Number(o2); - } - - private applyTemplate(template: any) { - this.accountTypeData = this.getOptionNames(template.accountTypeOptions); - this.liabilityAccountOptions = template.accountingMappingOptions?.liabilityAccountOptions ?? []; - this.autoInterestPostWhenData = template.interestPostingPeriodTypeOptions ?? []; - this.cdr.detectChanges(); - - const isEditing = !!template.id; - this.DailyDepositSchemeProductDetailsForm.patchValue({ - accountTypeGroup: template.accountTypeGroup || 'DDS', - name: isEditing ? template.name : template.name || '', - shortName: isEditing ? template.shortName : template.shortName || '', - description: template.description || '', - autoInterestPost: template.interestPostingPeriodType ? true : (template.autoInterestPost ?? true), - autoInterestPostWhen: template.interestPostingPeriodType?.id ?? this.autoInterestPostWhenData[0]?.id ?? '', - autoInterestPostDay: template.autoInterestPostDay ?? template.frequencyFixDay ?? '', - interestRate: template.nominalAnnualInterestRate ?? 2, - accountType: template.accountType ?? this.accountTypeData[0] ?? null, - glAccountId: template.glAccountId ?? null, - effectiveTo: this.parseDate(template.effectiveTo), - endDate: this.parseDate(template.endDate), - introducerRequired: template.introducerRequired === true || template.introducerRequired === 'Yes', - shareLinkingEnabled: !!template.shareLinkingEnabled - }); - } - - get DailyDepositSchemeProductDetails() { - const formValue = { ...this.DailyDepositSchemeProductDetailsForm.value }; - const dateFormat = this.settingsService.dateFormat; - - if (formValue.effectiveTo instanceof Date) { - formValue.effectiveTo = this.dateUtils.formatDate(formValue.effectiveTo, dateFormat) || ''; - } - if (formValue.endDate instanceof Date) { - formValue.endDate = this.dateUtils.formatDate(formValue.endDate, dateFormat) || ''; - } - - // accountTypeGroup / autoInterestPost / autoInterestPostWhen are the legacy screen's own - // fields - they don't map to a real product API parameter by that name, so they're folded - // into the actual payload fields (description, autoPost, interestPostingPeriodType) by the - // parent component rather than sent through as-is. - return formValue; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html deleted file mode 100644 index 7b799e488..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.html +++ /dev/null @@ -1,109 +0,0 @@ - - - -
-
- -

Bank Interest Rate Chart  Reference / Editable

- - -
- - @if (_referenceVersionLabel) { -
- Version: {{ _referenceVersionLabel }} - @if (_referenceRemarks) { -  | {{ _referenceRemarks }} - } -
- } - - @if (_editableSlabs.length === 0) { -
-

No interest rate slabs defined. Click Add Slab to add one.

-
- } - - @if (_editableSlabs.length > 0) { -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sr.{{ slab.srNo }}Particular - - From - - To - - Period{{ slab.periodTypeLabel }}Rate (%) - -
-
- - - } -
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss deleted file mode 100644 index 21f95779c..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.scss +++ /dev/null @@ -1,215 +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/. - */ - -.rdpi-section { - border: 1px solid #e2e8f0; - border-radius: 10px; - overflow: hidden; - background: #fff; - - & + & { - margin-top: 16px; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: #f8f9fa; - border-bottom: 1px solid #e8eaed; - - i { - font-size: 14px; - color: #0ea5e9; - } - - h3 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: #202124; - } - } -} - -.rdpi-spacer { - flex: 1; -} - -.rdpi-content { - padding: 16px 24px; -} - -.rdpi-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 4px 16px; -} - -.rdpi-field { - width: 100%; - - &--full { - grid-column: 1 / -1; - } -} - -.rdpi-checkbox-row { - display: flex; - align-items: center; - padding: 4px 0; -} - -.rdpi-add-btn { - height: 36px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; -} - -.rdpi-chart-block { - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 16px; - margin-bottom: 16px; - background: #fafbfc; -} - -.rdpi-chart-actions { - display: flex; - justify-content: flex-end; -} - -.rdpi-slab-actions { - display: flex; - justify-content: center; - padding: 8px 0 16px; -} - -.rdpi-empty-msg { - text-align: center; - padding: 16px; - - h3 { - margin: 0; - font-size: 14px; - font-weight: 500; - color: #5f6368; - } -} - -.rdpi-table-wrapper { - border: 1px solid #e2e8f0; - border-radius: 8px; - overflow: hidden; - margin-top: 8px; - - table { - width: 100%; - } - - tr.incentives-row { - height: 0; - - > td { - padding: 0; - } - } -} - -.rdpi-incentives { - overflow: hidden; -} - -.rdpi-incentive-card { - border-radius: 0; - padding: 16px; -} - -.rdpi-incentive-title { - margin: 0 0 8px; - font-size: 14px; - font-weight: 600; - color: #202124; -} - -.rdpi-incentive-add { - margin-bottom: 12px; -} - -@media (width <= 600px) { - .rdpi-grid { - grid-template-columns: 1fr; - } -} - -/* ── Editable reference-rate table ───────────────────────────── */ -.rdpi-ref-section { - margin-top: 24px; - border-color: #bfdbfe; - background: #f0f7ff; - - .rdpi-section__header { - background: #dbeafe; - color: #1e3a5f; - } -} - -.rdpi-ref-badge { - font-size: 11px; - font-weight: 500; - background: #3b82f6; - color: #fff; - border-radius: 10px; - padding: 2px 8px; - vertical-align: middle; -} - -.rdpi-ref-meta { - padding: 8px 16px; - font-size: 13px; - color: #555; - border-bottom: 1px solid #bfdbfe; -} - -.rdpi-ref-table { - width: 100%; -} - -.rdpi-inline-input { - border: 1px solid #cbd5e1; - border-radius: 4px; - padding: 3px 6px; - font-size: 13px; - width: 100%; - background: #fff; - - &:focus { - outline: none; - border-color: #3b82f6; - } - - &--num { - width: 70px; - } - - &--rate { - width: 80px; - } -} - -.rdpi-ref-footer { - padding: 10px 16px; -} - -.rdpi-ref-hint { - font-size: 12px; - color: #64748b; - margin: 0; -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts deleted file mode 100644 index f8ac4d193..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-interest-rate-chart-step/daily-deposit-scheme-product-interest-rate-chart-step.component.ts +++ /dev/null @@ -1,717 +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, OnChanges, Input, inject } from '@angular/core'; -import { - UntypedFormGroup, - UntypedFormBuilder, - Validators, - UntypedFormArray, - ReactiveFormsModule -} from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; -import { animate, state, style, transition, trigger } from '@angular/animations'; - -/** Custom Components */ -import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; -import { DepositProductIncentiveFormDialogComponent } from 'app/products/deposit-product-incentive-form-dialog/deposit-product-incentive-form-dialog.component'; - -/** Dialog Components */ -import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; -import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base'; -import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; - -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; -import { TranslateService } from '@ngx-translate/core'; -import { MatButton } from '@angular/material/button'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; - -import { MatDivider } from '@angular/material/divider'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; -import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; -import { FindPipe } from '../../../../pipes/find.pipe'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-interest-rate-chart-step', - templateUrl: './daily-deposit-scheme-product-interest-rate-chart-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-interest-rate-chart-step.component.scss'], - animations: [ - trigger('expandChartSlab', [ - state('collapsed', style({ height: '0px', minHeight: '0' })), - state('expanded', style({ height: '*' })), - transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) - ]) - ], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, - MatDivider, - MatButton, - MatCheckbox, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - FindPipe - ] -}) -export class DailyDepositSchemeProductInterestRateChartStepComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - dialog = inject(MatDialog); - private dateUtils = inject(Dates); - private settingsService = inject(SettingsService); - private translateService = inject(TranslateService); - - @Input() DailyDepositSchemeProductsTemplate: any; - /** Reference rate chart fetched from m_rd_interest_rate_effective / m_rd_interest_rate_slab_history */ - @Input() set rdInterestRates(rates: any[]) { - if (rates && rates.length > 0) { - // Use the first active version's slabs as the editable working copy - const activeVersion = rates.find((v: any) => v.isActive) ?? rates[0]; - this._referenceVersionLabel = activeVersion.versionLabel; - this._referenceRemarks = activeVersion.remarks; - // Deep-clone slabs so the original is not mutated - this._editableSlabs = (activeVersion.slabs ?? []).map((s: any) => ({ ...s })); - this.ensureSlabRangeRules(this._editableSlabs); - } - } - - /** Slabs the user is editing in the reference-rate table */ - _editableSlabs: any[] = []; - _referenceVersionLabel = ''; - _referenceRemarks = ''; - - /** Columns for the editable reference-rate table */ - readonly refSlabColumns = [ - 'srNo', - 'description', - 'fromPeriod', - 'toPeriod', - 'periodTypeLabel', - 'annualInterestRate' - ]; - - /** Returns the payload to save as a new rate version (called by parent after product save) */ - get modifiedRdInterestRatePayload(): any { - return { - versionLabel: this._referenceVersionLabel || 'Rate Version ' + new Date().toISOString().slice(0, 10), - remarks: this._referenceRemarks, - slabs: this._editableSlabs.map((s: any, idx: number) => ({ - srNo: s.srNo ?? idx + 1, - description: s.description, - periodTypeEnum: s.periodTypeEnum ?? 1, - fromPeriod: s.fromPeriod, - toPeriod: s.toPeriod ?? null, - annualInterestRate: s.annualInterestRate - })) - }; - } - - editSlab(index: number, field: string, value: any) { - const slabs = this._editableSlabs.map((slab: any) => ({ ...slab })); - const current = slabs[index]; - if (!current) { - return; - } - - const nextValue = this.normalizeSlabValue(value); - const oldFrom = this.normalizeSlabValue(current.fromPeriod); - const oldTo = this.normalizeSlabValue(current.toPeriod); - - current[field] = nextValue; - - // Keep the first slab anchored at 0 so the chart always starts from zero. - if (index === 0 && field === 'fromPeriod') { - current.fromPeriod = 0; - } - - if (field === 'fromPeriod' && nextValue === null) { - current.fromPeriod = oldFrom; - } - - if (field === 'fromPeriod') { - const effectiveFrom = this.normalizeSlabValue(current.fromPeriod); - const delta = effectiveFrom - oldFrom; - - if (current.toPeriod !== null && current.toPeriod !== undefined) { - current.toPeriod = this.normalizeSlabValue(current.toPeriod) + delta; - } - - this.shiftFollowingSlabs(slabs, index, delta); - } - - if (field === 'toPeriod') { - if (index < slabs.length - 1 && nextValue === null) { - // Non-final slabs should always have a closing boundary so later slabs can stay contiguous. - current.toPeriod = oldTo; - } else { - const delta = (this.normalizeSlabValue(current.toPeriod) ?? 0) - (oldTo ?? 0); - this.shiftFollowingSlabs(slabs, index, delta); - } - } - - this.ensureSlabRangeRules(slabs); - this._editableSlabs = slabs; - } - - addSlab() { - const slabs = this._editableSlabs.map((slab: any) => ({ ...slab })); - const nextSr = slabs.length + 1; - const lastSlab = slabs[slabs.length - 1]; - const lastFrom = this.normalizeSlabValue(lastSlab?.fromPeriod) ?? 0; - - if (lastSlab && this.normalizeSlabValue(lastSlab.toPeriod) === null) { - // Split the open-ended tail so the new slab can become the new last row. - // This keeps the sequence contiguous while preserving the existing tail start. - lastSlab.toPeriod = lastFrom; - } - - const previousSlab = slabs[slabs.length - 1]; - this._editableSlabs = [ - ...slabs, - { - srNo: nextSr, - description: '', - periodTypeEnum: 1, - periodTypeLabel: 'Days', - fromPeriod: slabs.length === 0 ? 0 : this.nextFromPeriod(previousSlab), - toPeriod: null, - annualInterestRate: null - } - ]; - this.ensureSlabRangeRules(this._editableSlabs); - } - - private normalizeSlabValue(value: any): number | null { - if (value === null || value === undefined || value === '') { - return null; - } - - const parsed = Number(value); - return Number.isNaN(parsed) ? null : parsed; - } - - private nextFromPeriod(previousSlab: any): number { - const previousTo = this.normalizeSlabValue(previousSlab?.toPeriod); - const previousFrom = this.normalizeSlabValue(previousSlab?.fromPeriod) ?? 0; - return previousTo === null ? previousFrom + 1 : previousTo + 1; - } - - private shiftFollowingSlabs(slabs: any[], index: number, delta: number): void { - if (!delta) { - return; - } - - for (let i = index + 1; i < slabs.length; i++) { - const slab = slabs[i]; - const currentFrom = this.normalizeSlabValue(slab.fromPeriod); - const currentTo = this.normalizeSlabValue(slab.toPeriod); - - if (currentFrom !== null) { - slab.fromPeriod = currentFrom + delta; - } - - if (currentTo !== null) { - slab.toPeriod = currentTo + delta; - } - } - } - - private ensureSlabRangeRules(slabs: any[]): void { - if (slabs.length === 0) { - return; - } - - slabs[0].fromPeriod = 0; - - for (let i = 1; i < slabs.length; i++) { - const previous = slabs[i - 1]; - const previousTo = this.normalizeSlabValue(previous.toPeriod); - if (previousTo === null) { - slabs[i].fromPeriod = this.normalizeSlabValue(slabs[i].fromPeriod); - continue; - } - - slabs[i].fromPeriod = previousTo + 1; - } - - // Keep the last slab open-ended so the chart ends with null. - slabs[slabs.length - 1].toPeriod = null; - - // Prevent inverted ranges on intermediate rows. - for (let i = 0; i < slabs.length - 1; i++) { - const fromPeriod = this.normalizeSlabValue(slabs[i].fromPeriod); - const toPeriod = this.normalizeSlabValue(slabs[i].toPeriod); - if (fromPeriod !== null && toPeriod !== null && toPeriod < fromPeriod) { - slabs[i].toPeriod = fromPeriod; - } - } - } - - DailyDepositSchemeProductInterestRateChartForm: UntypedFormGroup; - - periodTypeData: any; - entityTypeData: any; - attributeNameData: any; - conditionTypeData: any; - genderData: any; - clientTypeData: any; - clientClassificationData: any; - incentiveTypeData: any; - - chartSlabsDisplayedColumns: any[] = []; - chartSlabsIncentivesDisplayedColumns: string[] = ['incentives']; - incentivesDisplayedColumns: string[] = [ - 'entityType', - 'attributeName', - 'conditionType', - 'attributeValue', - 'incentiveType', - 'amount', - 'actions' - ]; - - minDate = new Date(2000, 0, 1); - maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10)); - - expandChartSlabIndex: number[] = []; - chartDetailData: any = []; - chartsDetail: any[] = []; - - /** - * @param {FormBuilder} formBuilder Form Builder. - * @param {MatDialog} dialog Dialog reference. - * @param {Dates} dateUtils Date Utils to format date. - * @param {SettingsService} settingsService Settings Service. - */ - - constructor() { - this.createDailyDepositSchemeProductInterestRateChartForm(); - } - - ngOnInit() { - this.periodTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.periodTypes; - this.entityTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.entityTypeOptions; - this.attributeNameData = this.DailyDepositSchemeProductsTemplate.chartTemplate.attributeNameOptions; - this.conditionTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.conditionTypeOptions; - this.genderData = this.DailyDepositSchemeProductsTemplate.chartTemplate.genderOptions; - this.clientTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.clientTypeOptions; - this.clientClassificationData = this.DailyDepositSchemeProductsTemplate.chartTemplate.clientClassificationOptions; - this.incentiveTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.incentiveTypeOptions; - - // assignFormData() intentionally not called — old chart form is removed from UI. - // The DailyDepositSchemeProductInterestRateChartForm is kept only for stepControl - // validity; it remains empty (valid) so the preview tab becomes available. - } - - // Assign form the values for edit Daily Deposit Scheme - assignFormData() { - this.addChart(); - const isChartArray = Array.isArray(this.DailyDepositSchemeProductsTemplate.activeChart); - if (this.DailyDepositSchemeProductsTemplate.activeChart) { - if (!isChartArray) { - this.chartDetailData.push(this.DailyDepositSchemeProductsTemplate.activeChart); - } else { - this.chartDetailData = this.DailyDepositSchemeProductsTemplate.activeChart; - } - } - - // Build the array of Objects from the retrived value - this.getChartsDetailsData(); - - // Iterates for every chart in charts - this.charts.controls.forEach((chartDetailControl: UntypedFormGroup, i: number) => { - if (!this.chartsDetail[i]) { - return; - } - - // Iterate for every chartSlab in chart - this.chartsDetail[i].chartSlabs.forEach((chartSlabDetail: any, j: number) => { - const chartSlabInfo = this.formBuilder.group({ - id: [chartSlabDetail.id], - amountRangeFrom: [chartSlabDetail.amountRangeFrom], - amountRangeTo: [chartSlabDetail.amountRangeTo], - annualInterestRate: [ - chartSlabDetail.annualInterestRate, - Validators.required - ], - description: [ - chartSlabDetail.description, - Validators.required - ], - fromPeriod: [ - chartSlabDetail.fromPeriod, - Validators.required - ], - toPeriod: [chartSlabDetail.toPeriod], - periodType: [ - chartSlabDetail.periodType, - Validators.required - ], - incentives: this.formBuilder.array([]) - }); - const formArray = chartDetailControl.controls['chartSlabs'] as UntypedFormArray; - formArray.push(chartSlabInfo); - - // Iterate for every slab in chartSlab - const chartIncentiveControl = (chartDetailControl.controls['chartSlabs'] as UntypedFormArray).controls[j]; - - // Iterate to input all the incentive for particular chart slab - this.chartsDetail[i].chartSlabs[j].incentives.forEach((chartIncentiveDetail: any) => { - const incentiveInfo = this.formBuilder.group({ - amount: [ - chartIncentiveDetail.amount, - Validators.required - ], - attributeName: [ - chartIncentiveDetail.attributeName, - Validators.required - ], - attribureValue: [ - chartIncentiveDetail.attribureValue, - Validators.required - ], - conditionType: [ - chartIncentiveDetail.conditionType, - Validators.required - ], - entityType: [ - chartIncentiveDetail.entityType, - Validators.required - ], - incentiveType: [ - chartIncentiveDetail.incentiveType, - Validators.required - ] - }); - const newFormArray = (chartIncentiveControl as UntypedFormGroup).controls['incentives'] as UntypedFormArray; - newFormArray.push(incentiveInfo); - }); - }); - }); - } - - getChartsDetailsData() { - this.chartDetailData.forEach((chartData: any) => { - const chart: { - id?: any; - endDate: string | Date; - fromDate: string | Date; - isPrimaryGroupingByAmount: any; - name: any; - chartSlabs: any[]; - } = { - endDate: chartData.endDate ? new Date(chartData.endDate) : '', - fromDate: chartData.fromDate ? new Date(chartData.fromDate) : '', - isPrimaryGroupingByAmount: chartData.isPrimaryGroupingByAmount, - name: chartData.name, - chartSlabs: this.getChartSlabsData(chartData) - }; - if (chartData.id) { - chart['id'] = chartData.id; - } - this.chartsDetail.push(chart); - }); - this.DailyDepositSchemeProductInterestRateChartForm.patchValue({ - charts: this.chartsDetail - }); - } - - getChartSlabsData(chartData: any) { - const chartSlabs: any[] = []; - let chartSlabData: any[] = []; - const isChartSlabArray = Array.isArray(chartData.chartSlabs); - if (!isChartSlabArray) { - chartSlabData.push(chartData.chartSlabs); - } else { - chartSlabData = chartData.chartSlabs; - } - - chartSlabData.forEach((eachChartSlabData: any) => { - const chartSlab: { - id?: any; - periodType: any; - amountRangeFrom: any; - amountRangeTo: any; - annualInterestRate: any; - description: any; - fromPeriod: any; - toPeriod: any; - incentives: any[]; - } = { - periodType: eachChartSlabData.periodType.id, - amountRangeFrom: eachChartSlabData.amountRangeFrom, - amountRangeTo: eachChartSlabData.amountRangeTo, - annualInterestRate: eachChartSlabData.annualInterestRate, - description: eachChartSlabData.description ? eachChartSlabData.description : '', - fromPeriod: eachChartSlabData.fromPeriod, - toPeriod: eachChartSlabData.toPeriod, - incentives: this.getIncentivesData(chartSlabData) - }; - if (eachChartSlabData.id) { - chartSlab['id'] = eachChartSlabData.id; - } - chartSlabs.push(chartSlab); - }); - return chartSlabs; - } - - getIncentivesData(chartSlabData: any) { - const incentives: any[] = []; - let incentiveDatas: any[] = []; - if (chartSlabData.incentives) { - const isChartIncentiveArray = Array.isArray(chartSlabData.incentives); - if (!isChartIncentiveArray) { - incentiveDatas.push(chartSlabData.incentives); - } else { - incentiveDatas = chartSlabData.incentives; - } - incentiveDatas.forEach((incentiveData: any) => { - const incentive = { - amount: incentiveData.amount, - attributeName: incentiveData.attributeName, - attributeValue: incentiveData.attributeValue, - conditionType: incentiveData.conditionType, - entityType: incentiveData.entityType, - incentiveType: incentiveData.incentiveType - }; - incentives.push(incentive); - }); - } - return incentives; - } - - createDailyDepositSchemeProductInterestRateChartForm() { - this.DailyDepositSchemeProductInterestRateChartForm = this.formBuilder.group({ - charts: this.formBuilder.array([]) - }); - } - - get charts(): UntypedFormArray { - return this.DailyDepositSchemeProductInterestRateChartForm.get('charts') as UntypedFormArray; - } - - createChartForm(): UntypedFormGroup { - return this.formBuilder.group({ - id: [null], - name: [''], - description: [''], - fromDate: [ - '', - Validators.required - ], - endDate: [''], - isPrimaryGroupingByAmount: [false], - chartSlabs: this.formBuilder.array([], Validators.required) - }); - } - - addChart() { - this.charts.push(this.createChartForm()); - this.setConditionalControls(this.charts.length - 1); - } - - setConditionalControls(chartIndex: number) { - this.chartSlabsDisplayedColumns[chartIndex] = [ - 'period', - 'amountRange', - 'annualInterestRate', - 'description', - 'actions' - ]; - this.charts - .at(chartIndex) - .get('isPrimaryGroupingByAmount') - .valueChanges.subscribe((isPrimaryGroupingByAmount: boolean) => { - this.chartSlabsDisplayedColumns[chartIndex] = isPrimaryGroupingByAmount ? [ - 'amountRange', - 'period' - ] : [ - 'period', - 'amountRange' - ]; - this.chartSlabsDisplayedColumns[chartIndex].push('annualInterestRate', 'description', 'actions'); - }); - } - - getIncentives(chartSlabs: UntypedFormArray, chartSlabIndex: number): UntypedFormArray { - return chartSlabs.at(chartSlabIndex).get('incentives') as UntypedFormArray; - } - - addChartSlab(chartSlabs: UntypedFormArray) { - const data = { ...this.getData('Slab') }; - const dialogRef = this.dialog.open(FormDialogComponent, { data }); - dialogRef.afterClosed().subscribe((response: any) => { - if (response.data) { - response.data.addControl('incentives', this.formBuilder.array([])); - chartSlabs.push(response.data); - } - }); - } - - addIncentive(incentives: UntypedFormArray) { - const data = { ...this.getData('Incentive'), entityType: this.entityTypeData[0].id }; - const dialogRef = this.dialog.open(DepositProductIncentiveFormDialogComponent, { data }); - dialogRef.afterClosed().subscribe((response: any) => { - if (response.data) { - incentives.push(response.data); - } - }); - } - - editChartSlab(chartSlabs: UntypedFormArray, chartSlabIndex: number) { - const data = { ...this.getData('Slab', chartSlabs.at(chartSlabIndex).value), layout: { addButtonText: 'Edit' } }; - const dialogRef = this.dialog.open(FormDialogComponent, { data }); - dialogRef.afterClosed().subscribe((response: any) => { - if (response.data) { - chartSlabs.at(chartSlabIndex).patchValue(response.data.value); - } - }); - } - - editIncentive(incentives: UntypedFormArray, incentiveIndex: number) { - const data = { - ...this.getData('Incentive', incentives.at(incentiveIndex).value), - layout: { addButtonText: 'Edit' } - }; - const dialogRef = this.dialog.open(DepositProductIncentiveFormDialogComponent, { data }); - dialogRef.afterClosed().subscribe((response: any) => { - if (response.data) { - incentives.at(incentiveIndex).patchValue(response.data.value); - } - }); - } - - getData(formType: string, values?: any) { - switch (formType) { - case 'Slab': - return { - title: this.translateService.instant('labels.inputs.Slab'), - formfields: this.getSlabFormfields(values) - }; - case 'Incentive': - return { values, chartTemplate: this.DailyDepositSchemeProductsTemplate.chartTemplate }; - } - } - - getSlabFormfields(values?: any) { - const formfields: FormfieldBase[] = [ - new SelectBase({ - controlName: 'periodType', - label: this.translateService.instant('labels.inputs.Period Type'), - value: values ? values.periodType : this.periodTypeData[0].id, - options: { label: 'value', value: 'id', data: this.periodTypeData }, - required: true, - order: 1 - }), - new InputBase({ - controlName: 'fromPeriod', - label: this.translateService.instant('labels.inputs.Period From'), - value: values ? values.fromPeriod : undefined, - type: 'number', - required: true, - order: 2, - min: 0 - }), - new InputBase({ - controlName: 'toPeriod', - label: this.translateService.instant('labels.inputs.Period To'), - value: values ? values.toPeriod : undefined, - type: 'number', - order: 3, - min: 0 - }), - new InputBase({ - controlName: 'amountRangeFrom', - label: this.translateService.instant('labels.inputs.Amount Range From'), - value: values ? values.amountRangeFrom : undefined, - type: 'number', - order: 4, - min: 0 - }), - new InputBase({ - controlName: 'amountRangeTo', - label: this.translateService.instant('labels.inputs.Amount Range To'), - value: values ? values.amountRangeTo : undefined, - type: 'number', - order: 5, - min: 0 - }), - new InputBase({ - controlName: 'annualInterestRate', - label: this.translateService.instant('labels.inputs.Interest'), - value: values ? values.annualInterestRate : undefined, - type: 'number', - required: true, - order: 6, - min: 0 - }), - new InputBase({ - controlName: 'description', - label: this.translateService.instant('labels.inputs.Description'), - value: values ? values.description : undefined, - required: true, - order: 7 - }) - ]; - return formfields; - } - - get DailyDepositSchemeProductInterestRateChart() { - // TODO: Update once language and date settings are setup - const dateFormat = 'YYYY-MM-DD'; - const locale = this.settingsService.language.code; - const DailyDepositSchemeProductInterestRateChart = this.DailyDepositSchemeProductInterestRateChartForm.value; - for (const chart of DailyDepositSchemeProductInterestRateChart.charts) { - chart.locale = locale; - chart.dateFormat = 'yyyy-MM-dd'; - if (chart.fromDate instanceof Date) { - chart.fromDate = this.dateUtils.formatDateAsString(chart.fromDate, dateFormat); - } - if (chart.endDate) { - if (chart.endDate instanceof Date) { - chart.endDate = this.dateUtils.formatDateAsString(chart.endDate, dateFormat); - } - } - if (chart.endDate === '') { - delete chart.endDate; - } - if (chart.description === '') { - delete chart.description; - } - if (chart.id === null) { - delete chart.id; - } - } - return { - ...DailyDepositSchemeProductInterestRateChart, - rdSlabs: this._editableSlabs - }; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html deleted file mode 100644 index 2227219c8..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.html +++ /dev/null @@ -1,732 +0,0 @@ - - -
- -

{{ DailyDepositSchemeProduct.name }}

- - - -

{{ 'labels.inputs.Details' | translate }}

- - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ DailyDepositSchemeProduct.shortName }} -
- - @if (DailyDepositSchemeProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ DailyDepositSchemeProduct.description }} -
- } - - - @if ( - DailyDepositSchemeProduct.accountType || - DailyDepositSchemeProduct.customerSegment || - DailyDepositSchemeProduct.glAccountId || - DailyDepositSchemeProduct.effectiveTo || - DailyDepositSchemeProduct.introducerRequired !== undefined || - DailyDepositSchemeProduct.shareLinkingEnabled !== undefined - ) { -

Product Classification

- - - @if (DailyDepositSchemeProduct.accountType) { -
- Account Type: - {{ DailyDepositSchemeProduct.accountType }} -
- } - - @if (DailyDepositSchemeProduct.glAccountId) { -
- GL Account (Liability): - {{ getGlAccountName(DailyDepositSchemeProduct.glAccountId) }} -
- } - - @if (DailyDepositSchemeProduct.effectiveTo) { -
- Effective To: - {{ DailyDepositSchemeProduct.effectiveTo | dateFormat }} -
- } - - @if (DailyDepositSchemeProduct.endDate) { -
- End Date: - {{ DailyDepositSchemeProduct.endDate | dateFormat }} -
- } - - @if (DailyDepositSchemeProduct.introducerRequired !== undefined) { -
- Introducer Required: - {{ - DailyDepositSchemeProduct.introducerRequired === true || - DailyDepositSchemeProduct.introducerRequired === 'Yes' - ? 'Yes' - : 'No' - }} -
- } - - @if (DailyDepositSchemeProduct.shareLinkingEnabled !== undefined) { -
- Deposit Account Link: - {{ DailyDepositSchemeProduct.shareLinkingEnabled | yesNo }} -
- } - } - - -

{{ 'labels.inputs.Currency' | translate }}

- - -
- {{ 'labels.inputs.Currency' | translate }}: - {{ - DailyDepositSchemeProduct.currencyCode - | find: DailyDepositSchemeProductsTemplate.currencyOptions : 'code' : 'name' - }} -
- -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ DailyDepositSchemeProduct.digitsAfterDecimal }} -
- - @if (DailyDepositSchemeProduct.dormancyAlert !== undefined) { -
- Dormancy Alert: - {{ DailyDepositSchemeProduct.dormancyAlert | yesNo }} -
- } - - @if (DailyDepositSchemeProduct.nominationFacility !== undefined) { -
- Nomination Facility: - {{ DailyDepositSchemeProduct.nominationFacility | yesNo }} -
- } - - @if (DailyDepositSchemeProduct.autoDebitFacility !== undefined) { -
- Auto Debit Facility: - {{ DailyDepositSchemeProduct.autoDebitFacility | yesNo }} -
- } - - @if ( - DailyDepositSchemeProduct.minDepositAmount !== undefined || DailyDepositSchemeProduct.maxDepositAmount !== undefined - ) { - -

Deposit Amount

- - - @if (DailyDepositSchemeProduct.minDepositAmount !== undefined) { -
- Minimum: - {{ DailyDepositSchemeProduct.minDepositAmount }} -
- } - - @if (DailyDepositSchemeProduct.maxDepositAmount !== undefined) { -
- Maximum: - {{ DailyDepositSchemeProduct.maxDepositAmount }} -
- } - } - - -

{{ 'labels.inputs.Terms' | translate }}

- - -
- {{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%): - {{ DailyDepositSchemeProduct.nominalAnnualInterestRate | formatNumber }} % -
- -
- {{ 'labels.inputs.Interest Compounding Period' | translate }}: - {{ - DailyDepositSchemeProduct.interestCompoundingPeriodType - | find: DailyDepositSchemeProductsTemplate.interestCompoundingPeriodTypeOptions : 'id' : 'value' - }} -
- -
- {{ 'labels.inputs.Interest Posting Period' | translate }}: - {{ - DailyDepositSchemeProduct.interestPostingPeriodType - | find: DailyDepositSchemeProductsTemplate.interestPostingPeriodTypeOptions : 'id' : 'value' - }} -
- - @if (DailyDepositSchemeProduct.interestCalculateSelector) { -
- {{ 'labels.inputs.Interest Calculation Type' | translate }}: - {{ - DailyDepositSchemeProduct.interestCalculateSelector - | find: DailyDepositSchemeProductsTemplate.interestCalculationTypeOption : 'id' : 'value' - }} -
- } - -
- {{ 'labels.inputs.Interest Calculated using' | translate }}: - {{ - DailyDepositSchemeProduct.interestCalculationType - | find: DailyDepositSchemeProductsTemplate.interestCalculationTypeOptions : 'id' : 'value' - | translateKey: 'catalogs' - }} -
- -
- {{ 'labels.inputs.Days in Year' | translate }}: - {{ - DailyDepositSchemeProduct.interestCalculationDaysInYearType - | find: DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearTypeOptions : 'id' : 'value' - }} -
- - @if (DailyDepositSchemeProduct.effectiveDate) { -
- Effective Date: - {{ DailyDepositSchemeProduct.effectiveDate | dateFormat }} -
- } - - @if (DailyDepositSchemeProduct.averagePeriod) { -
- Average Period: - {{ DailyDepositSchemeProduct.averagePeriod }} -
- } - -
- Auto Interest Post: - {{ DailyDepositSchemeProduct.autoPost | yesNo }} -
- - @if ( - DailyDepositSchemeProduct.frequencyFixDay !== undefined && - DailyDepositSchemeProduct.frequencyFixDay !== null && - DailyDepositSchemeProduct.frequencyFixDay !== '' - ) { -
- Frequency (Fix Day): - {{ DailyDepositSchemeProduct.frequencyFixDay }} -
- } - - @if (DailyDepositSchemeProduct.lastInterestDate) { -
- Last Interest Date: - {{ DailyDepositSchemeProduct.lastInterestDate | dateFormat }} -
- } - - -

{{ 'labels.heading.Settings' | translate }}

- - -
- Loan Facility: - {{ DailyDepositSchemeProduct.loanFacility | yesNo }} -
- -
- Premature Withdrawal: - {{ DailyDepositSchemeProduct.prematureWithdrawalOption | yesNo }} -
- -
- Tenure Flexibility: - {{ DailyDepositSchemeProduct.tenureFlexibility | yesNo }} -
- -
- Lien Marked: - {{ DailyDepositSchemeProduct.lienMarked | yesNo }} -
- -
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: - {{ DailyDepositSchemeProduct.withdrawalFeeForTransfers | yesNo }} -
- -
- Enable Lock-in Period: - {{ DailyDepositSchemeProduct.enableLockinPeriod | yesNo }} -
- - @if (DailyDepositSchemeProduct.enableLockinPeriod && DailyDepositSchemeProduct.lockinPeriodFrequency) { -
- Lock-in Period: - - {{ DailyDepositSchemeProduct.lockinPeriodFrequency }} - {{ getLockinPeriodFrequencyTypeLabel(DailyDepositSchemeProduct.lockinPeriodFrequencyType) }} - -
- } - -
- Maturity Instructions: - {{ DailyDepositSchemeProduct.maturityInstructions | yesNo }} -
- -
- Credit Maturity Proceeds: - {{ DailyDepositSchemeProduct.creditMaturityProceeds | yesNo }} -
- -
- Penalty Grace Period (Missed Installments): - {{ DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments | yesNo }} -
- -
- Panel Request Applicable: - {{ DailyDepositSchemeProduct.panelRequestApplicable | yesNo }} -
- -
- No Penalty (Nominee / Heir Closure): - {{ DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure | yesNo }} -
- -
- Deceased Settlement: - {{ DailyDepositSchemeProduct.deceasedSettlement | yesNo }} -
- -
- Allow Debit Txn After Due Date: - {{ DailyDepositSchemeProduct.allowDebitTxnAfterDueDate | yesNo }} -
- -
- Account Closed with Zero Balance in Day Closing: - {{ DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing | yesNo }} -
- - @if (DailyDepositSchemeProduct.minBalanceForInterestCalculation) { -
- Min Balance for Interest Calculation: - {{ DailyDepositSchemeProduct.minBalanceForInterestCalculation | formatNumber }} -
- } - - - @if (DailyDepositSchemeProduct.rdSlabs?.length) { - -

{{ 'labels.heading.Interest Rate Charts' | translate }}

- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? '—' }}Period{{ slab.periodTypeLabel }}Rate (%) - {{ slab.annualInterestRate }} -
-
- } - - - @if (DailyDepositSchemeProduct.charges?.length) { -
-

{{ 'labels.inputs.Charges' | translate }}

- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }}{{ charge.name + ', ' + charge.currency.displaySymbol }}{{ 'labels.inputs.Type' | translate }}{{ charge.chargeCalculationType.value }}{{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value }}
-
- } - - -

{{ 'labels.heading.Accounting' | translate }}

- - - @if (isCashOrAccrualAccounting()) { -
-
-

GL Mapping

-
- -
-
Assets
-
- Savings Reference Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.savingsReferenceAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
- @if (isAccrualAccounting()) { -
- Fees Receivable Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.feesReceivableAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
-
- Penalties Receivable Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.penaltiesReceivableAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
- } -
- -
-
Liabilities
-
- Savings Control Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.savingsControlAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
-
- Transfers In Suspense Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.transfersInSuspenseAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
- @if (isAccrualAccounting()) { -
- Interest Payable Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.interestPayableAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
- } -
- -
-
Expenses
-
- Interest on Savings Account - {{ - formatGlAccountById( - DailyDepositSchemeProduct.interestOnSavingsAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
- -
-
Income
-
- Income from Fees - {{ - formatGlAccountById( - DailyDepositSchemeProduct.incomeFromFeeAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions - ) - }} -
-
- Income from Penalties - {{ - formatGlAccountById( - DailyDepositSchemeProduct.incomeFromPenaltyAccountId, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions - ) - }} -
-
-
- } - - - @if (DailyDepositSchemeProduct.otherGlMappings) { -
-
-

Other GL Mappings

-
- - @if (DailyDepositSchemeProduct.otherGlMappings.assets?.length) { - - - - - - - - - - - -
Asset Account{{ row.accountName }}GL Account - {{ - formatGlAccountByCode( - row.glCode, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
- } - - @if (DailyDepositSchemeProduct.otherGlMappings.liabilities?.length) { - - - - - - - - - - - -
Liability Account{{ row.accountName }}GL Account - {{ - formatGlAccountByCode( - row.glCode, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
- } - - @if (DailyDepositSchemeProduct.otherGlMappings.expenses?.length) { - - - - - - - - - - - -
Expense Account{{ row.accountName }}GL Account - {{ - formatGlAccountByCode( - row.glCode, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
- } - - @if (DailyDepositSchemeProduct.otherGlMappings.income?.length) { - - - - - - - - - - - -
Income Account{{ row.accountName }}GL Account - {{ - formatGlAccountByCode( - row.glCode, - DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions - ) - }} -
- } -
- } - - - @if ( - DailyDepositSchemeProduct.advancedAccountingRules && - (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length || - DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length || - DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) - ) { -
-

{{ 'labels.heading.Advanced Accounting Rules' | translate }}

- - - @if (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length) { -
-

{{ 'labels.heading.Fund Sources for Payment Channels' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Payment Type' | translate }} - {{ item.paymentTypeId | find: DailyDepositSchemeProductsTemplate.paymentTypeOptions : 'id' : 'name' }} - {{ 'labels.inputs.Fund Source' | translate }} - {{ - item.fundSourceAccountId - | find - : DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions - : 'id' - : 'name' - }} -
-
- } - - @if (DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length) { -
-

{{ 'labels.heading.Fees to Specific Income Accounts' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Fees' | translate }} - {{ item.chargeId | find: DailyDepositSchemeProductsTemplate.chargeOptions : 'id' : 'name' }} - {{ 'labels.inputs.Income Account' | translate }} - {{ - item.incomeAccountId - | find - : DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions - : 'id' - : 'name' - }} -
-
- } - - @if (DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) { -
-

{{ 'labels.heading.Penalties to Specific Income Accounts' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Penalty' | translate }} - {{ item.chargeId | find: DailyDepositSchemeProductsTemplate.penaltyOptions : 'id' : 'name' }} - {{ 'labels.inputs.Income Account' | translate }} - {{ - item.incomeAccountId - | find - : DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions - : 'id' - : 'name' - }} -
-
- } -
- } -
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss deleted file mode 100644 index 33a0e1852..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.scss +++ /dev/null @@ -1,131 +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/. - */ - -@use 'assets/styles/colours' as *; - -.mat-h3 { - margin-top: 10px !important; - font-weight: 300; - color: $status-approved; -} - -.mat-elevation-z1 { - margin: 1em 0 1.5em; -} - -h2, -h3, -h4 { - margin: 0; - font-weight: 500; -} - -table { - width: 100%; -} - -span { - margin: 0.5em 0; -} - -mat-divider { - margin: 0 0 0.5em; -} - -/* ── GL mapping summary (same as savings product preview) ── */ -.spa-gl-summary, -.spa-other-gl-summary { - display: flex; - flex-direction: column; - gap: 0.9rem; - margin-top: 1.25rem; -} - -.spa-gl-summary__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.25rem 0 0.5rem; -} - -.spa-gl-section { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1rem 1.15rem; - border: 1px solid #e3e7f2; - border-radius: 12px; - background: #fff; - - h5 { - font-size: 0.9rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #91a0bc; - } -} - -.spa-gl-row { - display: grid; - grid-template-columns: minmax(220px, 40%) 1fr; - gap: 1rem; - align-items: baseline; - padding: 0.2rem 0; -} - -.spa-gl-row__label { - color: #1f2f4a; - font-weight: 500; -} - -.spa-gl-row__value { - color: #101828; - font-weight: 600; - word-break: break-word; -} - -.spa-other-gl-table { - margin-top: 0.5rem; - margin-bottom: 0.75rem; - table-layout: fixed; - width: 100%; - - &:first-of-type { - margin-top: 0.25rem; - } - - th.mat-mdc-header-cell, - td.mat-mdc-cell { - padding-left: 1.25rem; - padding-right: 1.25rem; - } - - th.mat-mdc-header-cell:first-child, - td.mat-mdc-cell:first-child { - width: 38%; - } - - th.mat-mdc-header-cell:last-child, - td.mat-mdc-cell:last-child { - width: 62%; - word-break: break-word; - } -} - -.spa-other-gl-summary .mat-elevation-z1 { - border-radius: 10px; - overflow: hidden; -} - -@media (width <= 768px) { - .spa-gl-row { - grid-template-columns: 1fr; - gap: 0.35rem; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts deleted file mode 100644 index 062751880..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component.ts +++ /dev/null @@ -1,142 +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/. - */ - -import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges, inject } from '@angular/core'; -import { Accounting } from 'app/core/utils/accounting'; -import { OptionData } from 'app/shared/models/option-data.model'; -import { MatDivider } from '@angular/material/divider'; -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { FindPipe } from '../../../../pipes/find.pipe'; -import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; -import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; -import { YesnoPipe } from '../../../../pipes/yesno.pipe'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-preview-step', - templateUrl: './daily-deposit-scheme-product-preview-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-preview-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatDivider, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - FaIconComponent, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - FindPipe, - DateFormatPipe, - FormatNumberPipe, - YesnoPipe - ] -}) -export class DailyDepositSchemeProductPreviewStepComponent implements OnInit, OnChanges { - private accounting = inject(Accounting); - - @Input() DailyDepositSchemeProductsTemplate: any; - @Input() chartSlabsDisplayedColumns: any[] = []; - @Input() accountingRuleData: any; - @Input() DailyDepositSchemeProduct: any; - @Output() submitEvent = new EventEmitter(); - - chargesDisplayedColumns: string[] = [ - 'name', - 'chargeCalculationType', - 'amount', - 'chargeTimeType' - ]; - paymentFundSourceDisplayedColumns: string[] = [ - 'paymentTypeId', - 'fundSourceAccountId' - ]; - feesPenaltyIncomeDisplayedColumns: string[] = [ - 'chargeId', - 'incomeAccountId' - ]; - - readonly rdSlabColumns = [ - 'srNo', - 'description', - 'fromPeriod', - 'toPeriod', - 'periodTypeLabel', - 'annualInterestRate' - ]; - - accountingRule: OptionData | undefined; - - ngOnInit() { - this.setCurrentValues(); - } - - ngOnChanges(_changes: SimpleChanges): void { - this.setCurrentValues(); - } - - setCurrentValues(): void { - if (this.DailyDepositSchemeProduct) { - this.accountingRule = this.accounting.getAccountingRuleFrom(this.DailyDepositSchemeProduct.accountingRule); - } - } - - isNoneAccounting(): boolean { - return this.accounting.isNoneAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule); - } - - isCashOrAccrualAccounting(): boolean { - return this.accounting.isCashOrAccrualAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule); - } - - isAccrualAccounting(): boolean { - return this.accounting.isAccrualAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule); - } - - getLockinPeriodFrequencyTypeLabel(value: any): string { - if (!value && value !== 0) return ''; - const options = this.DailyDepositSchemeProductsTemplate?.lockinPeriodFrequencyTypeOptions || []; - const found = options.find((o: any) => o.id === value); - return found?.value || String(value); - } - - getGlAccountName(id: any): string { - if (!id) return ''; - const accounts = this.DailyDepositSchemeProductsTemplate?.accountingMappingOptions?.liabilityAccountOptions ?? []; - const found = accounts.find((a: any) => a.id === id || a.id === +id); - return found ? `${found.name} (${found.glCode})` : String(id); - } - - formatGlAccountById(glAccountId: number, options: any[]): string { - if (!glAccountId || !Array.isArray(options)) return '-'; - const account = this.accounting.glAccountLookUp(glAccountId, options as any); - return account ? `(${account.glCode}) ${account.name}` : '-'; - } - - formatGlAccountByCode(glCode: string, options: any[]): string { - if (!glCode || !Array.isArray(options)) return glCode || '-'; - const account = options.find((o: any) => o?.glCode === glCode); - return account ? `(${account.glCode}) ${account.name}` : glCode; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html deleted file mode 100644 index 804ae09e9..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.html +++ /dev/null @@ -1,123 +0,0 @@ - - -
- -
-
- - Premature option -
-
-
- Loan Facility - Premature Withdrawal option - Tenure Flexibility - Lien Marked - Apply Withdrawal Fee for Transfers -
-
-
- - -
-
- - {{ 'labels.inputs.Lock-in Period' | translate }} -
-
-
- Enable Lock-in Period - -
- - @if (DailyDepositSchemeProductSettingsForm.value.enableLockinPeriod) { -
- - Frequency* - - - - - Type* - - @for (lockinPeriodFrequencyType of lockinPeriodFrequencyTypeData; track lockinPeriodFrequencyType) { - - {{ lockinPeriodFrequencyType.value | translateKey: 'catalogs' }} - - } - - -
- } -
-
- - -
-
- - Maturity Instructions -
-
-
- Maturity Instructions - -
-
- Option to credit maturity proceeds directly to the linked account or reinvest in another product (like - FD). - -
-
- Penalty / Grace Period for Missed Installments - -
-
- Panel Request Applicable - -
-
- No penalty for nominee / legal heir closure - -
-
- Deceased Settlement - -
-
- Allow Debit Txn after due date - -
-
-
- - -
-
- - Additional Rules -
-
-
-
- - Account Closed with Zero balance in Day Closing - -
- - - Balance Required for Interest Calculation - - -
-
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss deleted file mode 100644 index 3aefca054..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.scss +++ /dev/null @@ -1,164 +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/. - */ - -$accent: #0ea5e9; -$border: #e2e8f0; -$gray-50: #f8f9fa; -$gray-200: #e8eaed; -$gray-900: #202124; - -.rdps-section { - border: 1px solid $border; - border-radius: 10px; - background: #fff; - - & + & { - margin-top: 16px; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: $gray-50; - border-bottom: 1px solid $gray-200; - - i { - font-size: 14px; - color: $accent; - } - - span:not(.rdps-section__badge) { - margin: 0; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } - } -} - -.rdps-section__badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 6px; - background: #eff6ff; - font-size: 14px; -} - -.rdps-body { - background: #fff; - padding: 18px 24px 24px; -} - -.rdps-field { - width: 100%; - - &--full { - grid-column: 1 / -1; - } -} - -.rdps-premature-row { - display: flex; - align-items: center; - gap: 24px; - flex-wrap: wrap; - - mat-checkbox { - font-size: 14px; - color: $gray-900; - } -} - -.rdps-layout { - display: grid; - gap: 16px; -} - -.rdps-layout--additional { - grid-template-columns: repeat(2, minmax(0, 1fr)); - - @media (width <= 1100px) { - grid-template-columns: 1fr; - } -} - -.rdps-toggle { - display: flex; - align-items: center; - min-height: 40px; - - mat-checkbox { - font-size: 14px; - color: $gray-900; - } - - &--full { - min-height: auto; - margin-bottom: 16px; - } -} - -.rdps-body--list { - padding: 0 24px; -} - -.rdps-list-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 14px 0; - border-bottom: 1px solid $gray-200; - font-size: 14px; - color: $gray-900; - - &:last-child { - border-bottom: none; - } - - span { - max-width: 85%; - line-height: 1.4; - } -} - -.rdps-lockin-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; - padding-bottom: 16px; - border-bottom: 1px solid $gray-200; - font-size: 14px; - color: $gray-900; -} - -.rdps-lockin-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - - &--single { - grid-template-columns: minmax(0, 1fr); - max-width: 50%; - } - - @media (width <= 640px) { - grid-template-columns: 1fr; - - &--single { - max-width: 100%; - } - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts deleted file mode 100644 index b7f3d26ac..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component.ts +++ /dev/null @@ -1,95 +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/. - */ - -import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-settings-step', - templateUrl: './daily-deposit-scheme-product-settings-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-settings-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatCheckbox - ] -}) -export class DailyDepositSchemeProductSettingsStepComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - - @Input() DailyDepositSchemeProductsTemplate: any; - - DailyDepositSchemeProductSettingsForm: UntypedFormGroup; - - lockinPeriodFrequencyTypeData: any; - - constructor() { - this.createDailyDepositSchemeProductSettingsForm(); - } - - ngOnInit() { - this.lockinPeriodFrequencyTypeData = this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequencyTypeOptions; - - if (this.DailyDepositSchemeProductsTemplate?.id) { - this.DailyDepositSchemeProductSettingsForm.patchValue({ - loanFacility: this.DailyDepositSchemeProductsTemplate.loanFacility ?? false, - prematureWithdrawalOption: this.DailyDepositSchemeProductsTemplate.prematureWithdrawalOption ?? false, - tenureFlexibility: this.DailyDepositSchemeProductsTemplate.tenureFlexibility ?? false, - lienMarked: this.DailyDepositSchemeProductsTemplate.lienMarked ?? false, - withdrawalFeeForTransfers: this.DailyDepositSchemeProductsTemplate.withdrawalFeeForTransfers ?? false, - enableLockinPeriod: this.DailyDepositSchemeProductsTemplate.enableLockinPeriod ?? false, - lockinPeriodFrequency: this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequency ?? '', - lockinPeriodFrequencyType: this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequencyType?.id ?? '', - maturityInstructions: this.DailyDepositSchemeProductsTemplate.maturityInstructions ?? false, - creditMaturityProceeds: this.DailyDepositSchemeProductsTemplate.creditMaturityProceeds ?? false, - penaltyGracePeriodMissedInstallments: - this.DailyDepositSchemeProductsTemplate.penaltyGracePeriodMissedInstallments ?? false, - panelRequestApplicable: this.DailyDepositSchemeProductsTemplate.panelRequestApplicable ?? false, - noPenaltyNomineeHeirClosure: this.DailyDepositSchemeProductsTemplate.noPenaltyNomineeHeirClosure ?? false, - deceasedSettlement: this.DailyDepositSchemeProductsTemplate.deceasedSettlement ?? false, - allowDebitTxnAfterDueDate: this.DailyDepositSchemeProductsTemplate.allowDebitTxnAfterDueDate ?? false, - accountClosedZeroBalanceInDayClosing: - this.DailyDepositSchemeProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false, - minBalanceForInterestCalculation: this.DailyDepositSchemeProductsTemplate.minBalanceForInterestCalculation ?? '' - }); - } - } - - createDailyDepositSchemeProductSettingsForm() { - this.DailyDepositSchemeProductSettingsForm = this.formBuilder.group({ - loanFacility: [false], - prematureWithdrawalOption: [false], - tenureFlexibility: [false], - lienMarked: [false], - withdrawalFeeForTransfers: [false], - enableLockinPeriod: [false], - lockinPeriodFrequency: [''], - lockinPeriodFrequencyType: [''], - maturityInstructions: [false], - creditMaturityProceeds: [false], - penaltyGracePeriodMissedInstallments: [false], - panelRequestApplicable: [false], - noPenaltyNomineeHeirClosure: [false], - deceasedSettlement: [false], - allowDebitTxnAfterDueDate: [false], - accountClosedZeroBalanceInDayClosing: [false], - minBalanceForInterestCalculation: [''] - }); - } - - get DailyDepositSchemeProductSettings() { - const settings = { ...this.DailyDepositSchemeProductSettingsForm.value }; - for (const key in settings) { - if (settings[key] === '') { - delete settings[key]; - } - } - return settings; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html deleted file mode 100644 index 45c6f2100..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.html +++ /dev/null @@ -1,192 +0,0 @@ - - -
-
-
- - Deposit Amount -
-
-
- - Minimum - - - - - Maximum - - -
-
-
-
-
- - Interest Rate & Posting Terms -
-
-
- ℹ️ - - Daily Deposit Scheme base rate: - {{ DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').value }}% - -
- -
- - {{ 'labels.inputs.Nominal Annual Interest' | translate }} - - @if (DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').hasError('required')) { - - {{ 'labels.inputs.Nominal Annual Interest' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - @if (DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) { - {{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater - } - - - - Effective Date - - - - - - - {{ 'labels.inputs.Interest Calculation Type' | translate }} - - @for (opt of interestCalculationTypeOptionData; track opt.id) { - {{ opt.value }} - } - - -
- -
- - Interest Calculate - - @for (mode of interestCalculationTypeData; track mode.id) { - {{ mode.value | translateKey: 'catalogs' }} - } - - - {{ 'labels.inputs.Interest Calculation Type' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - Interest Compounding Period - - @for (opt of interestCompoundingPeriodTypeData; track opt.id) { - {{ opt.value | translateKey: 'catalogs' }} - } - - Interest Compounding Period is required - - - - Days in Year - - @for (opt of interestCalculationDaysInYearTypeData; track opt.id) { - {{ opt.value | translateKey: 'catalogs' }} - } - - Days in Year is required - - - @if (DailyDepositSchemeProductTermsForm.value.interestCalculationType === 2) { - - Average Period - - @for (period of periodFrequencyData; track period.id) { - {{ period.value }} - } - - - } - - - {{ 'labels.inputs.Interest Posting Period' | translate }} - - @for (interestPostingPeriodType of interestPostingPeriodTypeData; track interestPostingPeriodType) { - - {{ interestPostingPeriodType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest Posting Period' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - -
- - -
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss deleted file mode 100644 index b2e03a37d..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.scss +++ /dev/null @@ -1,151 +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/. - */ - -$accent: #0ea5e9; -$border: #e2e8f0; -$gray-50: #f8f9fa; -$gray-200: #e8eaed; -$gray-900: #202124; - -.rdpt-section { - border: 1px solid $border; - border-radius: 10px; - background: #fff; - - & + & { - margin-top: 16px; - } - - &__header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 24px; - background: $gray-50; - border-bottom: 1px solid $gray-200; - - i { - font-size: 14px; - color: $accent; - } - - h3, - span:not(.rdpt-section__badge) { - margin: 0; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } - } -} - -.rdpt-section__badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 6px; - background: #eff6ff; - font-size: 14px; -} - -.rdpt-body { - background: #fff; -} - -.rdpt-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 4px 16px; - padding: 16px 24px; - - &--three { - grid-template-columns: repeat(3, 1fr); - } - - @media (width <= 600px) { - grid-template-columns: 1fr; - - &--three { - grid-template-columns: 1fr; - } - } -} - -.rdpt-field { - width: 100%; - - &--full { - grid-column: 1 / -1; - } -} - -.rdpt-banner { - display: flex; - align-items: center; - gap: 10px; - margin: 16px 24px 0; - padding: 12px 14px; - background: #eff6ff; - border: 1px solid #dbeafe; - border-radius: 8px; - color: #1d4ed8; - font-size: 13px; - - &__icon { - font-size: 14px; - } -} - -.rdpt-footer { - padding: 0 24px 24px; - border-top: 1px solid #f1f5f9; -} - -.rdpt-footer__grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; - padding-top: 18px; - - @media (width <= 900px) { - grid-template-columns: 1fr; - } -} - -.rdpt-toggle-card { - padding: 12px 0; - - h4 { - margin: 0 0 8px; - font-size: 14px; - font-weight: 600; - color: $gray-900; - } - - &__choices { - display: flex; - align-items: center; - gap: 16px; - flex-wrap: wrap; - } -} - -.rdpt-note { - display: flex; - align-items: center; - gap: 10px; - margin-top: 18px; - padding: 12px 14px; - border: 1px solid #fde68a; - background: #fffbeb; - color: #b45309; - border-radius: 8px; - font-size: 12px; -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts deleted file mode 100644 index 0d42c8ff1..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component.ts +++ /dev/null @@ -1,169 +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/. - */ - -import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; -import { MatTooltip } from '@angular/material/tooltip'; -import { Dates } from 'app/core/utils/dates'; -import { SettingsService } from 'app/settings/settings.service'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-product-terms-step', - templateUrl: './daily-deposit-scheme-product-terms-step.component.html', - styleUrls: ['./daily-deposit-scheme-product-terms-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTooltip - ] -}) -export class DailyDepositSchemeProductTermsStepComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - private dateUtils = inject(Dates); - private settingsService = inject(SettingsService); - - @Input() DailyDepositSchemeProductsTemplate: any; - - DailyDepositSchemeProductTermsForm: UntypedFormGroup; - - interestCompoundingPeriodTypeData: any; - interestPostingPeriodTypeData: any; - interestCalculationTypeData: any; - interestCalculationTypeOptionData: any; - interestCalculationDaysInYearTypeData: any; - periodFrequencyData: any[] = []; - yesNoOptions: Array<{ value: boolean; label: string }> = [ - { value: true, label: 'Yes' }, - { value: false, label: 'No' } - ]; - - constructor() { - this.createDailyDepositSchemeProductTermsForm(); - } - - ngOnInit() { - this.interestCompoundingPeriodTypeData = - this.DailyDepositSchemeProductsTemplate.interestCompoundingPeriodTypeOptions; - this.interestPostingPeriodTypeData = this.DailyDepositSchemeProductsTemplate.interestPostingPeriodTypeOptions; - // Standard enum (1=Daily, 2=Average) → "Interest Calculate" dropdown → interestCalculationType form control - this.interestCalculationTypeData = this.DailyDepositSchemeProductsTemplate.interestCalculationTypeOptions; - // CBS code values → "Interest Calculation Type" dropdown → interestCalculateSelector form control - this.interestCalculationTypeOptionData = - this.DailyDepositSchemeProductsTemplate.interestCalculationTypeOption || []; - this.interestCalculationDaysInYearTypeData = - this.DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearTypeOptions; - this.periodFrequencyData = this.DailyDepositSchemeProductsTemplate.periodFrequencyOptions || []; - this.setYesNoOptions(this.DailyDepositSchemeProductsTemplate?.yesNoOptions); - - const isEditing = !!this.DailyDepositSchemeProductsTemplate.id; - const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly'; - const savedEnumTypeId = - this.DailyDepositSchemeProductsTemplate.interestCalculationType?.id || - this.interestCalculationTypeData?.[0]?.id || - 1; - const savedSelectorId = isEditing - ? (this.DailyDepositSchemeProductsTemplate.interestCalculateSelector ?? - this.interestCalculationTypeOptionData?.[0]?.id ?? - null) - : (this.interestCalculationTypeOptionData?.[0]?.id ?? null); - - this.DailyDepositSchemeProductTermsForm.patchValue({ - nominalAnnualInterestRate: this.DailyDepositSchemeProductsTemplate.nominalAnnualInterestRate ?? 0, - interestCompoundingPeriodType: this.DailyDepositSchemeProductsTemplate.interestCompoundingPeriodType?.id, - interestPostingPeriodType: this.DailyDepositSchemeProductsTemplate.interestPostingPeriodType?.id, - interestCalculationType: savedEnumTypeId, - interestCalculationDaysInYearType: this.DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearType?.id, - effectiveDate: this.DailyDepositSchemeProductsTemplate.effectiveDate - ? this.parseDate(this.DailyDepositSchemeProductsTemplate.effectiveDate) - : '', - interestCalculateSelector: savedSelectorId, - averagePeriod: isEditing ? this.DailyDepositSchemeProductsTemplate.averagePeriod || defaultPeriod : defaultPeriod, - autoPost: isEditing ? (this.DailyDepositSchemeProductsTemplate.autoPost ?? true) : true, - frequencyFixDay: isEditing ? (this.DailyDepositSchemeProductsTemplate.frequencyFixDay ?? 30) : 30, - lastInterestDate: this.DailyDepositSchemeProductsTemplate.lastInterestDate - ? this.parseDate(this.DailyDepositSchemeProductsTemplate.lastInterestDate) - : '', - minDepositAmount: this.DailyDepositSchemeProductsTemplate.minDepositAmount ?? '', - maxDepositAmount: this.DailyDepositSchemeProductsTemplate.maxDepositAmount ?? '' - }); - } - - private parseDate(dateValue: any): Date | null { - if (!dateValue) return null; - if (Array.isArray(dateValue)) { - return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); - } - if (dateValue instanceof Date) return dateValue; - return new Date(dateValue); - } - - private setYesNoOptions(options: any[]) { - if (!Array.isArray(options)) return; - const mapped = options - .map((opt: any) => { - const raw = opt?.name ?? opt?.value ?? opt; - const norm = typeof raw === 'string' ? raw.trim().toUpperCase() : raw; - if (norm === 'YES') return { value: true, label: 'Yes' }; - if (norm === 'NO') return { value: false, label: 'No' }; - return null; - }) - .filter(Boolean); - if (mapped.length) this.yesNoOptions = mapped; - } - - createDailyDepositSchemeProductTermsForm() { - this.DailyDepositSchemeProductTermsForm = this.formBuilder.group({ - nominalAnnualInterestRate: [ - '', - [ - Validators.required, - Validators.min(0) - ] - ], - interestCompoundingPeriodType: [ - '', - Validators.required - ], - interestPostingPeriodType: [ - '', - Validators.required - ], - interestCalculationType: [ - '', - Validators.required - ], - interestCalculationDaysInYearType: [ - '', - Validators.required - ], - effectiveDate: [''], - interestCalculateSelector: [1], - averagePeriod: ['Monthly'], - autoPost: [true], - frequencyFixDay: [ - 30, - [Validators.min(0)] - ], - lastInterestDate: [''], - minDepositAmount: [''], - maxDepositAmount: [''] - }); - } - - get DailyDepositSchemeProductTerms() { - const formValue = { ...this.DailyDepositSchemeProductTermsForm.value }; - const dateFormat = this.settingsService.dateFormat; - - if (formValue.effectiveDate instanceof Date) { - formValue.effectiveDate = this.dateUtils.formatDate(formValue.effectiveDate, dateFormat) || ''; - } - formValue.minDepositAmount = formValue.minDepositAmount; - formValue.maxDepositAmount = formValue.maxDepositAmount; - return formValue; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts deleted file mode 100644 index 0ec83acee..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-product.resolver.ts +++ /dev/null @@ -1,37 +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 { Injectable, inject } from '@angular/core'; -import { ActivatedRouteSnapshot } from '@angular/router'; - -/** rxjs Imports */ -import { Observable } from 'rxjs'; - -/** Custom Services */ -import { ProductsService } from '../products.service'; - -/** - * Daily Deposit Scheme Product data resolver. - * - * Reuses the Recurring Deposit product read API — a Daily Deposit Scheme - * product is a Recurring Deposit product under the hood. - */ -@Injectable() -export class DailyDepositSchemeProductResolver { - private productsService = inject(ProductsService); - - /** - * Returns the Daily Deposit Scheme product data. - * @returns {Observable} - */ - resolve(route: ActivatedRouteSnapshot): Observable { - const productId = route.parent.paramMap.get('productId'); - return this.productsService.getRecurringDepositProduct(productId); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html deleted file mode 100644 index b2973f1f2..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.html +++ /dev/null @@ -1,172 +0,0 @@ - - -
-
-
-
-
-

{{ 'labels.heading.Daily Deposit Scheme Products' | translate }}

-

Manage Daily Deposit Scheme products — daily instalment amount, interest rates and charges.

-
-
-
-
- - {{ totalCount }} - {{ totalCount === 1 ? 'product' : 'products' }} -
- -
-
- -
-
-
-
-
{{ totalCount | number }}
-
Total DDS Products
-
-
-
-
-
-
{{ currencyCount | number }}
-
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
-
-
-
-
-
-
{{ filteredCount | number }}
-
Showing after filter
-
-
-
- -
-
- -
- - @if (totalCount === 0) { -
-
-

No Daily Deposit Scheme products yet

-

Create your first Daily Deposit Scheme product to enable daily-instalment onboarding.

- -
- } @else { -
- - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }} -
-
-
-
{{ p.name }}
- @if (p.currency?.code) { -
{{ p.currency.code }}
- } -
-
-
{{ 'labels.inputs.Short Name' | translate }} - {{ p.shortName }} - Currency - @if (p.currency?.code) { - {{ p.currency.code }} - } @else { - — - } - Actions - - -
- - @if (filteredCount === 0 && totalCount > 0) { -
- - No products match your filter. -
- } - - -
- } -
-
diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss deleted file mode 100644 index a880f9b22..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.scss +++ /dev/null @@ -1,17 +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/. - */ - -@use 'assets/styles/product-list' as pl; - -@include pl.product-list-styles; - -/* Daily Deposit Scheme accent */ -:host { - --pl-accent: #0f766e; - --pl-accent-l: #ccfbf1; -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts deleted file mode 100644 index 7af670304..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.component.ts +++ /dev/null @@ -1,124 +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, ViewChild, inject } from '@angular/core'; -import { MatPaginator } from '@angular/material/paginator'; -import { MatSort, MatSortHeader } from '@angular/material/sort'; -import { - MatTableDataSource, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; -import { ActivatedRoute } from '@angular/router'; -import { MatTooltip } from '@angular/material/tooltip'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -/** - * Daily Deposit Scheme Products component. - * - * Lists Daily Deposit Scheme products. These are Recurring Deposit products - * under the hood (same create/read API) so this list reuses that data source. - */ -@Component({ - selector: 'mifosx-daily-deposit-scheme-products', - templateUrl: './daily-deposit-scheme-products.component.html', - styleUrls: ['./daily-deposit-scheme-products.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTooltip, - FaIconComponent, - MatTable, - MatSort, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatSortHeader, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - MatPaginator - ] -}) -export class DailyDepositSchemeProductsComponent implements OnInit { - private route = inject(ActivatedRoute); - - /** Data table data. */ - productData: any[] = []; - /** Columns to be displayed in the Daily Deposit Scheme products table. */ - displayedColumns: string[] = [ - 'name', - 'shortName', - 'currency', - 'actions' - ]; - /** Data source for the table. */ - dataSource: MatTableDataSource; - - filteredCount = 0; - - get totalCount(): number { - return this.productData?.length || 0; - } - - get currencyCount(): number { - const set = new Set(); - for (const p of this.productData || []) { - if (p?.currency?.code) set.add(p.currency.code); - } - return set.size; - } - - /** Paginator for the table. */ - @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - /** Sorter for the table. */ - @ViewChild(MatSort, { static: true }) sort: MatSort; - - constructor() { - this.route.data.subscribe((data: { dailyDepositSchemeProducts: any }) => { - this.productData = (data.dailyDepositSchemeProducts || []).filter( - (product: any) => product.productCategory === 'DDS' - ); - }); - } - - ngOnInit() { - this.setProducts(); - } - - /** - * Filters data in the table based on the passed value. - * @param {string} filterValue Value to filter data. - */ - applyFilter(filterValue: string) { - this.dataSource.filter = filterValue.trim().toLowerCase(); - } - - /** - * Initializes the data source, paginator and sorter for the table. - */ - setProducts() { - this.dataSource = new MatTableDataSource(this.productData); - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - this.filteredCount = this.totalCount; - this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts b/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts deleted file mode 100644 index 544bdbe0c..000000000 --- a/src/app/products/daily-deposit-scheme-products/daily-deposit-scheme-products.resolver.ts +++ /dev/null @@ -1,35 +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 { Injectable, inject } from '@angular/core'; - -/** rxjs Imports */ -import { Observable } from 'rxjs'; - -/** Custom Services */ -import { ProductsService } from '../products.service'; - -/** - * Daily Deposit Scheme Products data resolver. - * - * A Daily Deposit Scheme product is a Recurring Deposit product under the hood, - * so this reuses the same read API as Recurring Deposit Products. - */ -@Injectable() -export class DailyDepositSchemeProductsResolver { - private productsService = inject(ProductsService); - - /** - * Returns the Daily Deposit Scheme products data. - * @returns {Observable} - */ - resolve(): Observable { - return this.productsService.getRecurringDepositProducts(); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts deleted file mode 100644 index ca7c90dfb..000000000 --- a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver.ts +++ /dev/null @@ -1,35 +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 { Injectable, inject } from '@angular/core'; -import { ActivatedRouteSnapshot } from '@angular/router'; - -/** rxjs Imports */ -import { Observable } from 'rxjs'; - -/** Custom Services */ -import { ProductsService } from '../../products.service'; - -/** - * Recurring Deposits Account Template resolver. - */ -@Injectable() -export class DailyDepositSchemeProductAndTemplateResolver { - private productsService = inject(ProductsService); - - /** - * Returns the Recurring Deposits Product and Template. - * @param {ActivatedRouteSnapshot} route Route Snapshot - * @returns {Observable} - */ - resolve(route: ActivatedRouteSnapshot): Observable { - const productId = route.parent.paramMap.get('productId'); - return this.productsService.getRecurringDepositProductAndTemplate(productId); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html deleted file mode 100644 index 3ce623701..000000000 --- a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.html +++ /dev/null @@ -1,202 +0,0 @@ - - -
-
- -

{{ 'labels.heading.Edit Daily Deposit Scheme Product' | translate }}

-
- - - - - - {{ 'labels.inputs.DETAILS' | translate }} - -
- -
-
- - - - - {{ 'labels.inputs.CURRENCY' | translate }} - -
- -
-
- - - - - {{ 'labels.inputs.TERMS' | translate }} - -
- -
-
- - - - - {{ 'labels.inputs.SETTINGS' | translate }} - -
- -
-
- - - - - CBS Base Chart - -
- @if (activeBaseVersion) { -
-
-
-

{{ activeBaseVersion.versionLabel }}

-

- Active CBS base chart - Effective from {{ activeBaseVersion.effectiveFromDate }} -

-
- Active -
- - @if (activeBaseVersion.remarks) { -

{{ activeBaseVersion.remarks }}

- } - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? 'N/A' }}Period Type{{ slab.periodTypeLabel }}Annual Rate (%) - {{ slab.annualInterestRate | number: '1.2-2' }} -
-
- } @else { -

Loading CBS base chart...

- } -
-
- - - - - {{ 'labels.inputs.CHARGES' | translate }} - -
- -
-
- - - - - {{ 'labels.inputs.ACCOUNTING' | translate }} - -
- - -
-
- - @if (DailyDepositSchemeProductFormValid) { - - - - {{ 'labels.inputs.PREVIEW' | translate }} - -
- -
-
- } -
- -
- -
- @if (selectedTabIndex > 0) { - - } - @if (selectedTabIndex < totalTabs - 1) { - - } - @if (DailyDepositSchemeProductFormValid && selectedTabIndex === totalTabs - 1) { - - } -
-
-
diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss deleted file mode 100644 index c04c383f8..000000000 --- a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.scss +++ /dev/null @@ -1,138 +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/. - */ - -$primary: #1353a0; - -.rdp-container { - max-width: 960px; - margin: 0 auto; - padding: 24px 16px; -} - -.rdp-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 20px; - - &__icon { - font-size: 20px; - color: $primary; - } - - &__title { - margin: 0; - font-size: 22px; - font-weight: 500; - } -} - -.rdp-tabs { - background: var(--md-sys-color-surface, #fff); - border-radius: 8px; - box-shadow: 0 2px 8px rgb(0 0 0 / 8%); - - ::ng-deep { - .mat-mdc-tab-header { - border-bottom: 1px solid rgb(0 0 0 / 8%); - } - - .mat-mdc-tab { - min-width: 100px; - padding: 0 12px; - height: 52px; - } - - .mat-mdc-tab-body-wrapper { - min-height: 200px; - } - - .mat-mdc-tab-body { - overflow: visible !important; - } - - .mat-mdc-tab-body-content { - overflow: visible !important; - } - } -} - -.rdp-tab-icon { - margin-right: 8px; - font-size: 14px; -} - -.rdp-tab-content { - padding: 24px 16px; -} - -.base-rate-card { - padding: 16px; - border: 1px solid #dbe4f0; - border-radius: 14px; - background: linear-gradient(180deg, #f8fbff 0%, #fff 100%); -} - -.base-rate-header { - display: flex; - align-items: start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.base-rate-title { - margin: 0; - font-size: 18px; - font-weight: 600; -} - -.base-rate-meta { - margin: 6px 0 0; - color: #5b6b7f; - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.base-rate-badge { - padding: 4px 10px; - border-radius: 999px; - background: #dcfce7; - color: #166534; - font-size: 12px; - font-weight: 700; -} - -.base-rate-remarks { - margin: 0 0 12px; - color: #334155; -} - -.base-rate-table { - width: 100%; - border-collapse: collapse; -} - -.rdp-action-bar { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 20px; - padding: 16px 0; - border-top: 1px solid rgb(0 0 0 / 8%); - - &__cancel { - color: rgb(0 0 0 / 60%); - } - - &__right { - display: flex; - gap: 12px; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts b/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts deleted file mode 100644 index 38d715746..000000000 --- a/src/app/products/daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component.ts +++ /dev/null @@ -1,305 +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, ViewChild, inject } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - -/** Custom Components */ -import { DailyDepositSchemeProductDetailsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component'; -import { DailyDepositSchemeProductCurrencyStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component'; -import { DailyDepositSchemeProductTermsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component'; -import { DailyDepositSchemeProductSettingsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component'; -import { DailyDepositSchemeProductChargesStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component'; -import { DailyDepositSchemeProductAccountingStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component'; -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; - -/** Custom Services */ -import { ProductsService } from 'app/products/products.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Accounting } from 'app/core/utils/accounting'; -import { Dates } from 'app/core/utils/dates'; -import { MatTabsModule } from '@angular/material/tabs'; -import { DailyDepositSchemeProductPreviewStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-edit-daily-deposit-scheme-product', - templateUrl: './edit-daily-deposit-scheme-product.component.html', - styleUrls: ['./edit-daily-deposit-scheme-product.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTabsModule, - DailyDepositSchemeProductDetailsStepComponent, - DailyDepositSchemeProductCurrencyStepComponent, - DailyDepositSchemeProductTermsStepComponent, - DailyDepositSchemeProductSettingsStepComponent, - DailyDepositSchemeProductChargesStepComponent, - DailyDepositSchemeProductAccountingStepComponent, - DailyDepositSchemeProductPreviewStepComponent, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow - ] -}) -export class EditDailyDepositSchemeProductComponent { - private route = inject(ActivatedRoute); - private productsService = inject(ProductsService); - private router = inject(Router); - private settingsService = inject(SettingsService); - private accounting = inject(Accounting); - private dateUtils = inject(Dates); - - @ViewChild(DailyDepositSchemeProductDetailsStepComponent, { static: true }) - DailyDepositSchemeProductDetailsStep: DailyDepositSchemeProductDetailsStepComponent; - @ViewChild(DailyDepositSchemeProductCurrencyStepComponent, { static: true }) - DailyDepositSchemeProductCurrencyStep: DailyDepositSchemeProductCurrencyStepComponent; - @ViewChild(DailyDepositSchemeProductTermsStepComponent, { static: true }) - DailyDepositSchemeProductTermsStep: DailyDepositSchemeProductTermsStepComponent; - @ViewChild(DailyDepositSchemeProductSettingsStepComponent, { static: true }) - DailyDepositSchemeProductSettingsStep: DailyDepositSchemeProductSettingsStepComponent; - @ViewChild(DailyDepositSchemeProductChargesStepComponent, { static: true }) - DailyDepositSchemeProductChargesStep: DailyDepositSchemeProductChargesStepComponent; - @ViewChild(DailyDepositSchemeProductAccountingStepComponent, { static: true }) - DailyDepositSchemeProductAccountingStep: DailyDepositSchemeProductAccountingStepComponent; - - DailyDepositSchemeProductsTemplate: any; - accountingRuleData: string[] = []; - rdInterestRates: any[] = []; - readonly rdSlabColumns = [ - 'srNo', - 'description', - 'fromPeriod', - 'toPeriod', - 'periodTypeLabel', - 'annualInterestRate' - ]; - selectedTabIndex = 0; - - /** - * @param {ActivatedRoute} route Activated Route. - * @param {ProductsService} productsService Products Service. - * @param {Router} router Router for navigation. - * @param {SettingsService} settingsService Settings Service. - */ - - constructor() { - this.route.data.subscribe((data: { DailyDepositSchemeProductAndTemplate: any }) => { - this.DailyDepositSchemeProductsTemplate = data.DailyDepositSchemeProductAndTemplate; - this.productsService.getFdBaseInterestRates().subscribe({ - next: (rates: any[]) => { - this.rdInterestRates = rates || []; - }, - error: () => { - this.rdInterestRates = []; - } - }); - }); - this.accountingRuleData = this.accounting.getAccountingRulesForSavings(); - } - - get DailyDepositSchemeProductDetailsForm() { - return this.DailyDepositSchemeProductDetailsStep?.DailyDepositSchemeProductDetailsForm; - } - - get DailyDepositSchemeProductCurrencyForm() { - return this.DailyDepositSchemeProductCurrencyStep?.DailyDepositSchemeProductCurrencyForm; - } - - get DailyDepositSchemeProductTermsForm() { - return this.DailyDepositSchemeProductTermsStep?.DailyDepositSchemeProductTermsForm; - } - - get DailyDepositSchemeProductSettingsForm() { - return this.DailyDepositSchemeProductSettingsStep?.DailyDepositSchemeProductSettingsForm; - } - - get DailyDepositSchemeProductAccountingForm() { - return this.DailyDepositSchemeProductAccountingStep?.DailyDepositSchemeProductAccountingForm; - } - - get totalTabs(): number { - return this.DailyDepositSchemeProductFormValid ? 8 : 7; - } - - get DailyDepositSchemeProductFormValid() { - return !!this.DailyDepositSchemeProductDetailsForm?.valid; - } - - get activeBaseVersion() { - const chart = - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ?? - this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ?? - this.rdInterestRates[0]; - if (chart) { - return { - ...chart, - versionLabel: chart.versionLabel?.replace('FD', 'RD'), - remarks: chart.remarks?.replace('FD', 'RD') - }; - } - return null; - } - - get activeBaseSlabs() { - return this.activeBaseVersion?.slabs || []; - } - - get DailyDepositSchemeProduct() { - // accountTypeGroup / autoInterestPost / autoInterestPostWhen are the legacy Account Type - // Creation screen's own fields (added for the simplified Create form) - not real product API - // parameters, so they must not leak into the update payload the way a plain spread would. - // accountTypeGroup is deliberately NOT re-folded into description here (unlike Create): it - // was already folded in once at creation time, and this getter re-runs on every change - // detection cycle while editing, so re-prepending it here would keep stacking duplicate - // "Group - " prefixes onto the same description. autoInterestPost/When aren't re-synced onto - // the Terms tab either, since Edit's own Terms/Settings/Accounting tabs remain fully visible - // and directly editable. - const { accountTypeGroup, autoInterestPost, autoInterestPostWhen, autoInterestPostDay, ...detailsPayload } = - this.DailyDepositSchemeProductDetailsStep.DailyDepositSchemeProductDetails; - - return { - ...detailsPayload, - ...this.DailyDepositSchemeProductCurrencyStep.DailyDepositSchemeProductCurrency, - ...this.DailyDepositSchemeProductTermsStep.DailyDepositSchemeProductTerms, - ...this.DailyDepositSchemeProductSettingsStep.DailyDepositSchemeProductSettings, - ...this.DailyDepositSchemeProductChargesStep.DailyDepositSchemeProductCharges, - ...this.DailyDepositSchemeProductAccountingStep.DailyDepositSchemeProductAccounting, - // charts intentionally omitted here - the backend no longer requires a charts payload for RD - // updates once rdSlabs is present, and this getter's chart data comes from the shared CBS - // base-rate template (not this product's own chart), which caused spurious - // "chart.overlapping.from.and.end.dates" errors when a product's real chart had different - // dates. Still included in DailyDepositSchemeProductPreview below for the read-only display tab. - rdSlabs: this.activeBaseSlabs - }; - } - - get DailyDepositSchemeProductPreview() { - return { - ...this.DailyDepositSchemeProduct, - charts: this.DailyDepositSchemeProductCharts, - rdSlabs: this.activeBaseSlabs - }; - } - - get DailyDepositSchemeProductCharts() { - const activeBaseVersion = this.activeBaseVersion; - if (!activeBaseVersion?.slabs?.length) { - return []; - } - const dateFormat = this.settingsService.dateFormat; - - return [ - { - name: activeBaseVersion.versionLabel || 'CBS Base Chart', - description: activeBaseVersion.remarks || activeBaseVersion.versionLabel || 'CBS base chart', - fromDate: - this.dateUtils.formatDate(activeBaseVersion.effectiveFromDate, dateFormat) || - activeBaseVersion.effectiveFromDate, - ...(activeBaseVersion.effectiveToDate - ? { - endDate: - this.dateUtils.formatDate(activeBaseVersion.effectiveToDate, dateFormat) || - activeBaseVersion.effectiveToDate - } - : {}), - isPrimaryGroupingByAmount: false, - locale: this.settingsService.language.code, - dateFormat, - chartSlabs: activeBaseVersion.slabs.map((slab: any) => { - const chartSlab: any = { - periodType: slab.periodTypeEnum, - fromPeriod: slab.fromPeriod, - toPeriod: slab.toPeriod, - annualInterestRate: slab.annualInterestRate, - description: slab.description || '', - incentives: [] as any[] - }; - - if (slab.amountRangeFrom !== null && slab.amountRangeFrom !== undefined && slab.amountRangeFrom !== '') { - chartSlab.amountRangeFrom = slab.amountRangeFrom; - } - - if (slab.amountRangeTo !== null && slab.amountRangeTo !== undefined && slab.amountRangeTo !== '') { - chartSlab.amountRangeTo = slab.amountRangeTo; - } - - return chartSlab; - }) - } - ] as any[]; - } - - submit() { - const DailyDepositSchemeProduct: any = this.pruneEmptyValues({ - ...this.DailyDepositSchemeProduct, - charges: (this.DailyDepositSchemeProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), - locale: this.settingsService.language.code, - dateFormat: this.settingsService.dateFormat - }); - if (!DailyDepositSchemeProduct.description) { - DailyDepositSchemeProduct.description = ''; - } - delete DailyDepositSchemeProduct.advancedAccountingRules; - const productId = this.DailyDepositSchemeProductsTemplate.id; - this.productsService - .updateRecurringDepositProduct(productId, DailyDepositSchemeProduct) - .subscribe((response: any) => { - this.router.navigate(['../'], { relativeTo: this.route }); - }); - } - - private pruneEmptyValues(value: T): T { - if (Array.isArray(value)) { - return value.map((item) => this.pruneEmptyValues(item)).filter((item) => item !== undefined) as T; - } - - if (value && typeof value === 'object') { - return Object.entries(value as Record).reduce( - (acc, [ - key, - currentValue - ]) => { - const cleanedValue = this.pruneEmptyValues(currentValue); - if (cleanedValue === undefined) { - return acc; - } - return { - ...acc, - [key]: cleanedValue - }; - }, - {} as Record - ) as T; - } - - if (value === '' || value === null || value === undefined) { - return undefined as T; - } - - return value; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html deleted file mode 100644 index c8d7995ea..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.html +++ /dev/null @@ -1,17 +0,0 @@ - - -
- - -
diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss deleted file mode 100644 index af9f4c075..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.scss +++ /dev/null @@ -1,7 +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/. - */ diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts deleted file mode 100644 index 4d5a895ed..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component.ts +++ /dev/null @@ -1,38 +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/. - */ - -import { Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { EntityDatatableTabComponent } from '../../../../shared/tabs/entity-datatable-tab/entity-datatable-tab.component'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-datatable-tab', - templateUrl: './daily-deposit-scheme-datatable-tab.component.html', - styleUrls: ['./daily-deposit-scheme-datatable-tab.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - EntityDatatableTabComponent - ] -}) -export class DailyDepositSchemeDatatableTabComponent { - private route = inject(ActivatedRoute); - - entityId: string; - entityDatatable: any; - multiRowDatatableFlag: boolean; - - constructor() { - this.entityId = this.route.parent.parent.snapshot.paramMap.get('productId'); - - this.route.data.subscribe((data: { recurringDepositDatatable: any }) => { - this.entityDatatable = data.recurringDepositDatatable; - this.multiRowDatatableFlag = this.entityDatatable.columnHeaders[0].columnName === 'id' ? true : false; - }); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html deleted file mode 100644 index 8881ab235..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.html +++ /dev/null @@ -1,843 +0,0 @@ - - -
- -
-
-
-
-
Daily Deposit Scheme Product
-

{{ DailyDepositSchemeProduct.name }}

-
- {{ DailyDepositSchemeProduct.shortName }} - @if (DailyDepositSchemeProduct.currency?.code) { - {{ DailyDepositSchemeProduct.currency.code }} - } - - - {{ DailyDepositSchemeProduct.endDate ? 'Inactive' : 'Active' }} - -
- @if (DailyDepositSchemeProduct.description) { -

{{ DailyDepositSchemeProduct.description }}

- } -
-
- -
- - -
-
-
-

{{ 'labels.inputs.Details' | translate }}

-
-
-
- {{ 'labels.inputs.Short Name' | translate }} - {{ DailyDepositSchemeProduct.shortName }} -
- @if (DailyDepositSchemeProduct.description) { -
- {{ 'labels.inputs.Description' | translate }} - {{ DailyDepositSchemeProduct.description }} -
- } -
-
- - -
-
-
-

Product Classification

-
-
- @if (DailyDepositSchemeProduct.accountType) { -
- Account Type - {{ DailyDepositSchemeProduct.accountType }} -
- } - @if (DailyDepositSchemeProduct.customerSegment) { -
- Customer Segment - {{ DailyDepositSchemeProduct.customerSegment }} -
- } - @if (DailyDepositSchemeProduct.glAccountId) { -
- GL Account (Liability) - {{ - DailyDepositSchemeProduct.glAccountName || DailyDepositSchemeProduct.glAccountId - }} -
- } - @if (DailyDepositSchemeProduct.effectiveTo) { -
- Effective To - {{ DailyDepositSchemeProduct.effectiveTo | dateFormat }} -
- } - @if (DailyDepositSchemeProduct.endDate) { -
- End Date - {{ DailyDepositSchemeProduct.endDate | dateFormat }} -
- } -
- Introducer Required - - - {{ DailyDepositSchemeProduct.introducerRequired | yesNo }} - - -
-
- Deposit Account Link - - - {{ DailyDepositSchemeProduct.shareLinkingEnabled | yesNo }} - - -
-
-
- - -
-
-
-

{{ 'labels.inputs.Currency' | translate }}

-
-
-
- {{ 'labels.inputs.Currency' | translate }} - ({{ DailyDepositSchemeProduct.currency.code }}) {{ DailyDepositSchemeProduct.currency.name }} -
-
- {{ 'labels.inputs.Decimal Places' | translate }} - {{ DailyDepositSchemeProduct.currency.decimalPlaces }} -
-
- Dormancy Alert - {{ DailyDepositSchemeProduct.dormancyAlert | yesNo }} -
-
- Balance Swap Option - {{ DailyDepositSchemeProduct.balanceSwapOption | yesNo }} -
-
- MSME - {{ DailyDepositSchemeProduct.msme | yesNo }} -
-
- Nomination Facility - {{ DailyDepositSchemeProduct.nominationFacility | yesNo }} -
-
- Auto Debit Facility - {{ DailyDepositSchemeProduct.autoDebitFacility | yesNo }} -
-
-
- - -
-
-
-

{{ 'labels.inputs.Terms' | translate }}

-
-
-
- {{ 'labels.inputs.Deposit Amount' | translate }} - - {{ DailyDepositSchemeProduct.depositAmount | formatNumber }} - (Min: {{ DailyDepositSchemeProduct.minDepositAmount || DailyDepositSchemeProduct.depositAmount }}, Max: - {{ DailyDepositSchemeProduct.maxDepositAmount || DailyDepositSchemeProduct.depositAmount }}) - -
-
- {{ 'labels.inputs.Nominal Annual Interest' | translate }} - {{ DailyDepositSchemeProduct.nominalAnnualInterestRate | formatNumber - }}% -
- @if (DailyDepositSchemeProduct.effectiveDate) { -
- Effective Date - {{ DailyDepositSchemeProduct.effectiveDate | dateFormat }} -
- } - @if (DailyDepositSchemeProduct.interestCalculationTypeName) { -
- {{ 'labels.inputs.Interest Calculation Type' | translate }} - {{ DailyDepositSchemeProduct.interestCalculationTypeName }} -
- } -
- Interest Calculate - {{ DailyDepositSchemeProduct.interestCalculationType?.value }} -
- @if (DailyDepositSchemeProduct.averagePeriod) { -
- Average Period - {{ DailyDepositSchemeProduct.averagePeriod }} -
- } -
- {{ 'labels.inputs.Interest Posting Period' | translate }} - {{ DailyDepositSchemeProduct.interestPostingPeriodType.value }} -
-
- {{ 'labels.inputs.Interest Compounding Period' | translate }} - {{ DailyDepositSchemeProduct.interestCompoundingPeriodType.value }} -
-
- {{ 'labels.inputs.Days in Year' | translate }} - {{ DailyDepositSchemeProduct.interestCalculationDaysInYearType.value }} -
-
- Auto Post - - - {{ DailyDepositSchemeProduct.autoPost | yesNo }} - - -
- @if (DailyDepositSchemeProduct.frequencyFixDay) { -
- Frequency (Fix Day) - {{ DailyDepositSchemeProduct.frequencyFixDay }} -
- } - @if (DailyDepositSchemeProduct.lastInterestDate) { -
- Last Interest Date - {{ DailyDepositSchemeProduct.lastInterestDate | dateFormat }} -
- } -
-
- - -
-
-
-

{{ 'labels.inputs.Settings' | translate }}

-
- -

Premature Option

-
-
- Loan Facility - - - {{ DailyDepositSchemeProduct.loanFacility | yesNo }} - - -
-
- Premature Withdrawal Option - - - {{ DailyDepositSchemeProduct.prematureWithdrawalOption | yesNo }} - - -
-
- Tenure Flexibility - - - {{ DailyDepositSchemeProduct.tenureFlexibility | yesNo }} - - -
-
- Lien Marked - - - {{ DailyDepositSchemeProduct.lienMarked | yesNo }} - - -
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }} - - - {{ DailyDepositSchemeProduct.withdrawalFeeForTransfers | yesNo }} - - -
-
- -

{{ 'labels.inputs.Lock-in Period' | translate }}

-
-
- Enable Lock-in Period - - - {{ DailyDepositSchemeProduct.enableLockinPeriod | yesNo }} - - -
- @if (DailyDepositSchemeProduct.lockinPeriodFrequency) { -
- {{ 'labels.inputs.Lock-in Period' | translate }} - {{ DailyDepositSchemeProduct.lockinPeriodFrequency }} - {{ DailyDepositSchemeProduct.lockinPeriodFrequencyType?.value }} -
- } -
- -

Maturity Instructions

-
-
- Maturity Instructions - - - {{ DailyDepositSchemeProduct.maturityInstructions | yesNo }} - - -
-
- Credit Maturity Proceeds - - - {{ DailyDepositSchemeProduct.creditMaturityProceeds | yesNo }} - - -
-
- Penalty Grace Period (Missed Installments) - - - {{ DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments | yesNo }} - - -
-
- Panel Request Applicable - - - {{ DailyDepositSchemeProduct.panelRequestApplicable | yesNo }} - - -
-
- No Penalty (Nominee / Heir Closure) - - - {{ DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure | yesNo }} - - -
-
- Deceased Settlement - - - {{ DailyDepositSchemeProduct.deceasedSettlement | yesNo }} - - -
-
- Allow Debit Txn After Due Date - - - {{ DailyDepositSchemeProduct.allowDebitTxnAfterDueDate | yesNo }} - - -
-
- -

Additional Rules

-
-
- Account Closed Zero Balance in Day Closing - - - {{ DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing | yesNo }} - - -
- @if (DailyDepositSchemeProduct.minBalanceForInterestCalculation) { -
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }} - {{ DailyDepositSchemeProduct.minBalanceForInterestCalculation | formatNumber }} -
- } - @if (DailyDepositSchemeProduct.minDepositTerm) { -
- {{ 'labels.inputs.Minimum Deposit Term' | translate }} - {{ DailyDepositSchemeProduct.minDepositTerm }} - {{ DailyDepositSchemeProduct.minDepositTermType?.value }} -
- } - @if (DailyDepositSchemeProduct.maxDepositTerm) { -
- {{ 'labels.inputs.Maximum Deposit Term' | translate }} - {{ DailyDepositSchemeProduct.maxDepositTerm }} - {{ DailyDepositSchemeProduct.maxDepositTermType?.value }} -
- } - @if (DailyDepositSchemeProduct.inMultipleOfDepositTerm) { -
- {{ 'labels.inputs.Deposit Term In multiple of' | translate }} - {{ DailyDepositSchemeProduct.inMultipleOfDepositTerm }} - - {{ DailyDepositSchemeProduct.inMultipleOfDepositTermType?.value }} -
- } - @if (DailyDepositSchemeProduct.isMandatoryDeposit !== undefined) { -
- {{ 'labels.inputs.Daily Deposit Scheme Type' | translate }} - {{ - DailyDepositSchemeProduct.isMandatoryDeposit - ? ('labels.text.Mandatory' | translate) - : ('labels.text.Voluntary' | translate) - }} -
- } - @if (DailyDepositSchemeProduct.preClosurePenalInterest) { -
- {{ 'labels.inputs.Apply penal interest (less)' | translate }} - {{ DailyDepositSchemeProduct.preClosurePenalInterest }}% On - {{ DailyDepositSchemeProduct.preClosurePenalInterestOnType?.value }} -
- } -
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }} - - - {{ DailyDepositSchemeProduct.withHoldTax | yesNo }} - - -
- @if (DailyDepositSchemeProduct.withHoldTax) { -
- {{ 'labels.inputs.Tax Group' | translate }} - {{ DailyDepositSchemeProduct.taxGroup?.name }} -
- } -
-
- - - @if (DailyDepositSchemeProduct.activeChart) { -
-
-
-

{{ 'labels.heading.Interest Rate Charts' | translate }}

-
- - -
- @if (DailyDepositSchemeProduct.activeChart.name) { - {{ DailyDepositSchemeProduct.activeChart.name }} - } - {{ DailyDepositSchemeProduct.activeChart.fromDate | dateFormat }} - @if (DailyDepositSchemeProduct.activeChart.endDate) { - - {{ DailyDepositSchemeProduct.activeChart.endDate | dateFormat }} - } - - Primary by Amount: {{ DailyDepositSchemeProduct.activeChart.isPrimaryGroupingByAmount | yesNo }} - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sr.{{ i + 1 }}Particular{{ chartSlab.description }}From{{ chartSlab.fromPeriod }}To{{ chartSlab.toPeriod ?? '—' }}Period{{ chartSlab.periodType?.value }}Rate (%) - {{ chartSlab.annualInterestRate }} - - - -
-

{{ 'labels.heading.Incentives' | translate }}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Entity Type' | translate }} - {{ - incentive.entityType - | find: DailyDepositSchemeProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value' - }} - {{ 'labels.inputs.Attribute Name' | translate }} - {{ - incentive.attributeName - | find - : DailyDepositSchemeProductTemplate.chartTemplate.attributeNameOptions - : 'id' - : 'value' - }} - {{ 'labels.inputs.Condition Type' | translate }} - {{ - incentive.conditionType - | find - : DailyDepositSchemeProductTemplate.chartTemplate.conditionTypeOptions - : 'id' - : 'value' - }} - {{ 'labels.inputs.Attribute Value' | translate }} - @switch (incentive.attributeName) { - @case (2) { - {{ - incentive.attributeValue - | find: DailyDepositSchemeProductTemplate.chartTemplate.genderOptions : 'id' : 'name' - }} - } - @case (3) { - {{ incentive.attributeValue }} - } - @case (4) { - {{ - incentive.attributeValue - | find - : DailyDepositSchemeProductTemplate.chartTemplate.clientTypeOptions - : 'id' - : 'name' - }} - } - @case (5) { - {{ - incentive.attributeValue - | find - : DailyDepositSchemeProductTemplate.chartTemplate.clientClassificationOptions - : 'id' - : 'name' - }} - } - } - {{ 'labels.inputs.Incentive Type' | translate }} - {{ - incentive.incentiveType - | find - : DailyDepositSchemeProductTemplate.chartTemplate.incentiveTypeOptions - : 'id' - : 'value' - }} - {{ 'labels.inputs.Amount' | translate }}{{ incentive.amount }}
-
-
-
-
-
- } - - - @if (DailyDepositSchemeProduct.charges.length) { -
-
-
-

{{ 'labels.heading.Charges' | translate }}

-
-
- - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }}{{ charge.name }}{{ 'labels.inputs.Type' | translate }}{{ charge.chargeCalculationType.value }}{{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value }}
-
-
- } - - -
-
-
-

{{ 'labels.heading.Accounting' | translate }}

-
- -
- - - @if ( - DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length || - DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length || - DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length - ) { -
-
-
-

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

-
- @if (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length) { -

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

-
- - - - - - - - - - - -
{{ 'labels.inputs.Payment Type' | translate }}{{ p.paymentType.name }}{{ 'labels.inputs.Fund Source' | translate }}{{ p.fundSourceAccount.name }}
-
- } - @if (DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length) { -

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

-
- - - - - - - - - - - -
{{ 'labels.inputs.Fee/Charge' | translate }}{{ f.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ f.incomeAccount.name }}
-
- } - @if (DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) { -

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

-
- - - - - - - - - - - -
{{ 'labels.inputs.Penalty' | translate }}{{ p.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ p.incomeAccount.name }}
-
- } -
- } -
diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss deleted file mode 100644 index 9e7b167c3..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.scss +++ /dev/null @@ -1,528 +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/. - */ - -:host { - --vsp-navy: #0b1e3d; - --vsp-blue: #1d4ed8; - --vsp-blue-l: #eff6ff; - --vsp-teal: #0e7e6b; - --vsp-teal-l: #ccfbf1; - --vsp-purple: #6d28d9; - --vsp-purple-l: #ede9fe; - --vsp-indigo: #3730a3; - --vsp-indigo-l: #e0e7ff; - --vsp-amber: #b45309; - --vsp-amber-l: #fef3c7; - --vsp-green: #15803d; - --vsp-green-l: #dcfce7; - --vsp-red: #c0392b; - --vsp-red-l: #fee2e2; - --vsp-pink: #be185d; - --vsp-pink-l: #fce7f3; - --vsp-g50: #f8fafc; - --vsp-g100: #f1f5f9; - --vsp-g200: #e2e8f0; - --vsp-g500: #64748b; - --vsp-g700: #334155; - --vsp-g900: #0f172a; - - display: block; - font-family: - 'DM Sans', - system-ui, - -apple-system, - sans-serif; - color: var(--vsp-g900); - font-size: 13.5px; -} - -.vsp-root { - padding: 4px 4px 24px; - display: flex; - flex-direction: column; - gap: 14px; -} - -.vsp-hero { - background: linear-gradient(135deg, var(--vsp-navy) 0%, var(--vsp-blue) 100%); - border-radius: 14px; - padding: 22px 22px 22px 18px; - display: flex; - align-items: flex-start; - gap: 18px; - box-shadow: - 0 10px 24px rgb(11 30 61 / 18%), - 0 4px 10px rgb(11 30 61 / 12%); - color: #fff; - position: relative; - overflow: hidden; -} - -.vsp-hero::before { - content: ''; - position: absolute; - top: -60px; - right: -60px; - width: 240px; - height: 240px; - border-radius: 50%; - background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%); - pointer-events: none; -} - -.vsp-hero-left { - display: flex; - align-items: flex-start; - gap: 16px; - flex: 1; - min-width: 0; -} - -.vsp-hero-icon { - width: 56px; - height: 56px; - border-radius: 14px; - background: rgb(255 255 255 / 16%); - border: 1px solid rgb(255 255 255 / 22%); - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 24px; - flex-shrink: 0; -} - -.vsp-hero-text { - flex: 1; - min-width: 0; -} - -.vsp-hero-eyebrow { - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.12em; - color: rgb(255 255 255 / 70%); - margin-bottom: 4px; -} - -.vsp-hero-title { - margin: 0 0 10px; - font-size: 22px; - font-weight: 700; - letter-spacing: 0.01em; - color: #fff; - line-height: 1.2; -} - -.vsp-hero-meta { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.vsp-hero-desc { - margin: 12px 0 0; - font-size: 13px; - color: rgb(255 255 255 / 82%); - line-height: 1.5; - max-width: 720px; -} - -.vsp-edit-btn { - display: inline-flex; - align-items: center; - gap: 8px; - height: 38px; - padding: 0 16px; - border-radius: 10px; - background: rgb(255 255 255 / 14%); - color: #fff; - border: 1px solid rgb(255 255 255 / 28%); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.02em; - cursor: pointer; - text-decoration: none; - white-space: nowrap; - font-family: inherit; - flex-shrink: 0; - transition: all 0.16s ease; - - fa-icon { - font-size: 12px; - } - - &:hover { - background: rgb(255 255 255 / 24%); - border-color: rgb(255 255 255 / 50%); - } -} - -.vsp-pill { - display: inline-flex; - align-items: center; - gap: 5px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - padding: 4px 10px; - border-radius: 999px; - font-family: inherit; - - fa-icon { - font-size: 9.5px; - } - - &--code { - background: rgb(255 255 255 / 18%); - color: #fff; - border: 1px solid rgb(255 255 255 / 24%); - font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; - } - - &--teal { - background: var(--vsp-teal-l); - color: var(--vsp-teal); - border: 1px solid #99f6e4; - } - - &--blue { - background: var(--vsp-blue-l); - color: var(--vsp-blue); - border: 1px solid #dbeafe; - } - - &--green { - background: var(--vsp-green-l); - color: var(--vsp-green); - border: 1px solid #bbf7d0; - } - - &--amber { - background: var(--vsp-amber-l); - color: var(--vsp-amber); - border: 1px solid #fde68a; - } - - &--red { - background: var(--vsp-red-l); - color: var(--vsp-red); - border: 1px solid #fecaca; - } - - &--gray { - background: var(--vsp-g100); - color: var(--vsp-g700); - border: 1px solid var(--vsp-g200); - } -} - -.vsp-section { - background: #fff; - border: 1px solid var(--vsp-g200); - border-radius: 12px; - padding: 16px 20px; - box-shadow: 0 1px 2px rgb(15 23 42 / 4%); -} - -.vsp-section-h { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 14px; - padding-bottom: 12px; - border-bottom: 1px dashed var(--vsp-g200); - - h3 { - margin: 0; - font-size: 14px; - font-weight: 700; - color: var(--vsp-g900); - letter-spacing: 0.02em; - } -} - -.vsp-section-ic { - width: 36px; - height: 36px; - border-radius: 10px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 14px; - flex-shrink: 0; - - &--blue { - background: var(--vsp-blue-l); - color: var(--vsp-blue); - } - - &--teal { - background: var(--vsp-teal-l); - color: var(--vsp-teal); - } - - &--purple { - background: var(--vsp-purple-l); - color: var(--vsp-purple); - } - - &--indigo { - background: var(--vsp-indigo-l); - color: var(--vsp-indigo); - } - - &--amber { - background: var(--vsp-amber-l); - color: var(--vsp-amber); - } - - &--green { - background: var(--vsp-green-l); - color: var(--vsp-green); - } - - &--red { - background: var(--vsp-red-l); - color: var(--vsp-red); - } - - &--pink { - background: var(--vsp-pink-l); - color: var(--vsp-pink); - } -} - -.vsp-sub-h { - margin: 16px 0 10px; - font-size: 12px; - font-weight: 700; - color: var(--vsp-g700); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.vsp-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px 24px; -} - -@media (width <=600px) { - .vsp-grid { - grid-template-columns: 1fr; - } -} - -.vsp-kv { - display: flex; - flex-direction: column; - gap: 3px; - padding: 8px 0; - border-bottom: 1px dashed var(--vsp-g100); - - &--span { - grid-column: 1 / -1; - } -} - -.vsp-k { - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--vsp-g500); -} - -.vsp-v { - font-size: 13.5px; - color: var(--vsp-g900); - font-weight: 500; - word-break: break-word; - - strong { - font-weight: 700; - } -} - -.vsp-v--accent { - font-size: 18px; - font-weight: 700; - color: var(--vsp-blue); - letter-spacing: 0.01em; -} - -.vsp-suffix { - font-size: 12px; - font-weight: 600; - color: var(--vsp-g500); - margin-left: 4px; -} - -.vsp-table-wrap { - overflow-x: auto; - border: 1px solid var(--vsp-g200); - border-radius: 10px; - margin-top: 4px; -} - -/* ───────── CHART META PILLS ───────── */ -.vsp-chart-meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 14px; -} - -.vsp-chart-pill { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - padding: 5px 12px; - border-radius: 8px; - background: var(--vsp-g100); - border: 1px solid var(--vsp-g200); - color: var(--vsp-g700); - - fa-icon { - font-size: 11px; - color: var(--vsp-g500); - } - - &--green { - background: var(--vsp-green-l); - border-color: #bbf7d0; - color: var(--vsp-green); - } - - &--red { - background: var(--vsp-red-l); - border-color: #fecaca; - color: var(--vsp-red); - } -} - -/* ───────── RATE BADGE ───────── */ -.vsp-rate-badge { - display: inline-block; - font-size: 13px; - font-weight: 700; - color: var(--vsp-blue); - background: var(--vsp-blue-l); - border: 1px solid #dbeafe; - border-radius: 6px; - padding: 3px 10px; - letter-spacing: 0.02em; -} - -/* ───────── CELL SUB TEXT ───────── */ -.vsp-cell-sub { - display: block; - font-size: 10.5px; - color: var(--vsp-g500); - margin-top: 2px; -} - -/* ───────── INCENTIVE EXPAND ───────── */ -.vsp-incentive-btn { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11.5px; - font-weight: 600; - padding: 5px 12px; - border-radius: 8px; - border: 1px solid var(--vsp-g200); - background: var(--vsp-g50); - color: var(--vsp-blue); - cursor: pointer; - font-family: inherit; - transition: all 0.14s ease; - - fa-icon { - font-size: 11px; - } - - &:hover { - background: var(--vsp-blue-l); - border-color: #dbeafe; - } -} - -.vsp-incentive-cell { - padding: 0 !important; -} - -.vsp-incentive-panel { - overflow: hidden; - padding: 0 14px; - background: var(--vsp-g50); - border-top: 1px dashed var(--vsp-g200); -} - -.vsp-incentive-title { - margin: 12px 0 8px; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--vsp-g500); -} - -.vsp-table { - width: 100%; - background: #fff; - - th.mat-mdc-header-cell { - background: var(--vsp-g50); - color: var(--vsp-g700); - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 11px 14px; - border-bottom: 1px solid var(--vsp-g200); - white-space: nowrap; - } - - td.mat-mdc-cell { - padding: 10px 14px; - border-bottom: 1px solid var(--vsp-g100); - font-size: 12.5px; - color: var(--vsp-g900); - } - - tbody tr:last-child td.mat-mdc-cell { - border-bottom: 0; - } - - tbody tr:not(.incentives-row, .vsp-row-even) td.mat-mdc-cell { - background: #fff !important; - } - - tbody tr.vsp-row-even td.mat-mdc-cell { - background: var(--vsp-g50) !important; - } - - tr.incentives-row { - height: 0; - - > td { - padding: 0; - } - - .incentives { - overflow: hidden; - } - } -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts deleted file mode 100644 index ca0a551e7..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component.ts +++ /dev/null @@ -1,121 +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/. - */ - -import { Component, inject } from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { trigger, state, transition, animate, style } from '@angular/animations'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { MatDivider } from '@angular/material/divider'; - -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; -import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component'; -import { FindPipe } from '../../../../pipes/find.pipe'; -import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; -import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; -import { YesnoPipe } from '../../../../pipes/yesno.pipe'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-daily-deposit-scheme-general-tab', - templateUrl: './daily-deposit-scheme-general-tab.component.html', - styleUrls: ['./daily-deposit-scheme-general-tab.component.scss'], - animations: [ - trigger('expandChartSlab', [ - state('collapsed', style({ height: '0px', minHeight: '0' })), - state('expanded', style({ height: '*' })), - transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) - ]) - ], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - RouterLink, - FaIconComponent, - MatDivider, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - ViewSavingsAccountingDetailsComponent, - FindPipe, - DateFormatPipe, - FormatNumberPipe, - YesnoPipe - ] -}) -export class DailyDepositSchemeGeneralTabComponent { - private route = inject(ActivatedRoute); - - DailyDepositSchemeProduct: any; - DailyDepositSchemeProductTemplate: any; - expandChartSlabIndex: number | null = null; - - chartSlabsIncentivesDisplayedColumns: string[] = ['incentives']; - readonly chartSlabsBaseColumns = [ - 'srNo', - 'description', - 'fromPeriod', - 'toPeriod', - 'periodTypeLabel', - 'annualInterestRate' - ]; - - get chartSlabsDisplayedColumns(): string[] { - const slabs = this.DailyDepositSchemeProduct?.activeChart?.chartSlabs || []; - const hasIncentives = slabs.some((s: any) => s.incentives?.length); - return hasIncentives ? [ - ...this.chartSlabsBaseColumns, - 'actions' - ] : this.chartSlabsBaseColumns; - } - incentivesDisplayedColumns: string[] = [ - 'entityType', - 'attributeName', - 'conditionType', - 'attributeValue', - 'incentiveType', - 'amount' - ]; - chargesDisplayedColumns: string[] = [ - 'name', - 'type', - 'amount', - 'collectedon' - ]; - paymentFundSourceDisplayedColumns: string[] = [ - 'paymentTypeId', - 'fundSourceAccountId' - ]; - feesPenaltyIncomeDisplayedColumns: string[] = [ - 'chargeId', - 'incomeAccountId' - ]; - - constructor() { - this.route.data.subscribe((data: { DailyDepositSchemeProduct: any; DailyDepositSchemeProductsTemplate: any }) => { - this.DailyDepositSchemeProduct = data.DailyDepositSchemeProduct; - this.DailyDepositSchemeProductTemplate = data.DailyDepositSchemeProductsTemplate; - }); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html deleted file mode 100644 index 2295a36d2..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.html +++ /dev/null @@ -1,68 +0,0 @@ - - -
- @if (!rdInterestRates || rdInterestRates.length === 0) { -

No CBS base interest rate versions defined.

- } - - - @for (version of rdInterestRates; track version.id) { - - - - - {{ version.versionLabel }} - - {{ version.isActive ? 'Active' : 'Inactive' }} - - Effective from: {{ version.effectiveFromDate }} - - - - - @if (version.remarks) { -

- {{ version.remarks }} -

- } - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Sr.{{ slab.srNo }}Particular{{ slab.description }}From{{ slab.fromPeriod }}To{{ slab.toPeriod ?? '—' }}Period Type{{ slab.periodTypeLabel }}Annual Rate (%) - {{ slab.annualInterestRate | number: '1.2-2' }} -
-
- } -
-
diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss deleted file mode 100644 index 1728940c9..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.scss +++ /dev/null @@ -1,40 +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/. - */ - -.rd-version-header { - display: flex; - align-items: center; - gap: 12px; -} - -.rd-active-badge { - background: #4caf50; - color: #fff; - border-radius: 4px; - padding: 2px 8px; - font-size: 12px; -} - -.rd-inactive-badge { - background: #9e9e9e; - color: #fff; - border-radius: 4px; - padding: 2px 8px; - font-size: 12px; -} - -.rd-slab-table { - width: 100%; - margin-top: 8px; -} - -.no-rates { - padding: 24px; - color: #757575; - text-align: center; -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts deleted file mode 100644 index ef3ce4220..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates-tab.component.ts +++ /dev/null @@ -1,66 +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/. - */ - -import { Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { DecimalPipe } from '@angular/common'; -import { - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow -} from '@angular/material/table'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-rd-interest-rates-tab', - templateUrl: './rd-interest-rates-tab.component.html', - styleUrls: ['./rd-interest-rates-tab.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTable, - MatColumnDef, - MatHeaderCellDef, - MatHeaderCell, - MatCellDef, - MatCell, - MatHeaderRowDef, - MatHeaderRow, - MatRowDef, - MatRow, - MatExpansionModule, - DecimalPipe - ] -}) -export class DailyDepositSchemeInterestRatesTabComponent { - private route = inject(ActivatedRoute); - - rdInterestRates: any[] = []; - - readonly slabColumns = [ - 'srNo', - 'description', - 'fromPeriod', - 'toPeriod', - 'periodTypeLabel', - 'annualInterestRate' - ]; - - constructor() { - this.route.data.subscribe((data: { rdInterestRates: any[] }) => { - this.rdInterestRates = data.rdInterestRates || []; - }); - } -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts deleted file mode 100644 index 68e82a666..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-interest-rates-tab/daily-deposit-scheme-interest-rates.resolver.ts +++ /dev/null @@ -1,16 +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/. - */ - -import { inject } from '@angular/core'; -import { ResolveFn } from '@angular/router'; -import { ProductsService } from '../../../products.service'; - -export const rdInterestRatesResolver: ResolveFn = (route) => { - const productsService = inject(ProductsService); - return productsService.getRdBaseInterestRates(); -}; diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html deleted file mode 100644 index 041cff962..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss deleted file mode 100644 index 74229104a..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.scss +++ /dev/null @@ -1,22 +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/. - */ - -.product-card { - margin: 0 auto; - max-width: 80rem; - width: 90%; - padding: 0; - - .navigation-tabs { - overflow: auto; - } - - i:hover { - cursor: pointer; - } -} diff --git a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts b/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts deleted file mode 100644 index 5e871f560..000000000 --- a/src/app/products/daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component.ts +++ /dev/null @@ -1,44 +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/. - */ - -import { Component, inject } from '@angular/core'; -import { ActivatedRoute, RouterLinkActive, RouterLink, RouterOutlet, Router } from '@angular/router'; -import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -@Component({ - selector: 'mifosx-view-daily-deposit-scheme-product', - templateUrl: './view-daily-deposit-scheme-product.component.html', - styleUrls: ['./view-daily-deposit-scheme-product.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatTabNav, - MatTabLink, - RouterLinkActive, - RouterLink, - MatTabNavPanel, - RouterOutlet - ] -}) -export class ViewDailyDepositSchemeProductComponent { - private route = inject(ActivatedRoute); - private router = inject(Router); - - recurringDepositDatatables: any = []; - - constructor() { - this.route.data.subscribe((data: { recurringDepositDatatables: any }) => { - this.recurringDepositDatatables = []; - data.recurringDepositDatatables.forEach((datatable: any) => { - if (datatable.entitySubType === 'Daily Deposit Scheme') { - this.recurringDepositDatatables.push(datatable); - } - }); - }); - } -} diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html new file mode 100644 index 000000000..761aa3737 --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html @@ -0,0 +1,126 @@ + + +
+
+
+
+
+

Account Type Creation

+

{{ isEdit ? 'Update this Daily Deposit scheme variant.' : 'Register a Daily Deposit scheme variant.' }}

+
+
+ +
+
+ + Account Type Group + + @for (group of accountTypeGroups; track group) { + {{ group }} + } + + + + + A/c.Type + + + + + A/c. Type Description + + + + + Auto Interest Post + + Yes + No + + + + + Auto Interest Post When + + @for (option of autoInterestPostWhenOptions; track option) { + {{ option }} + } + + + + + @if (autoPostOn) { + + Auto Interest Post Day + + + } + + + + Maturity Auto Close + + Yes + No + + Closes the account on its maturity date, if it names a savings account to be paid into + +
+ + +

Premature Closure

+

+ Applied when an account on this scheme is closed before its term ends. The branch chooses at the time + whether the customer or the agent bears the cost; these set what that costs. +

+ +
+ + Minimum Lock-in (months) + + Before this, early closure is refused outright. 0 allows it from day one. + + + + Customer: interest rate reduced by (%) + + Taken off the rate for the whole period, not only the part left to run. + + + + Agent: commission reduced by (%) + + Charged whoever bears the interest — the round was not kept going. + +
+ +
+ + +
+
+
+
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss new file mode 100644 index 000000000..67afb70be --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss @@ -0,0 +1,112 @@ +/** + * 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/. + */ + +.dat-root { + padding: 20px 24px 40px; +} + +.dat-card { + background: #fff; + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 10px; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); + overflow: hidden; + max-width: 880px; + margin: 0 auto; +} + +.dat-head { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + border-bottom: 1px solid rgb(0 0 0 / 7%); + + h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + } + + p { + margin: 2px 0 0; + font-size: 0.83rem; + color: rgb(0 0 0 / 55%); + } +} + +.dat-head__ic { + width: 46px; + height: 46px; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 1.05rem; + color: #fff; + background: linear-gradient(135deg, #0f766e, #134e4a); + flex: none; +} + +.dat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 8px 20px; + padding: 26px 24px 8px; +} + +.dat-field { + width: 100%; +} + +.dat-field--wide { + grid-column: 1 / -1; +} + +/* + * Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a + * second card, because it is the same record being filled in - the break is to stop three money + * figures reading as more of the posting settings above them. + */ +.dat-section { + margin: 20px 0 0; + padding: 18px 24px 0; + border-top: 1px solid rgb(0 0 0 / 7%); + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #0f766e; +} + +.dat-section-note { + margin: 6px 0 0; + padding: 0 24px; + max-width: 68ch; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 60%); +} + +/* The note already carries the spacing, so the grid under it does not add its own again. */ +.dat-section-note + .dat-grid { + padding-top: 14px; +} + +.dat-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 7%); +} + +@media (width <= 720px) { + .dat-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts new file mode 100644 index 000000000..dc7911ba3 --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts @@ -0,0 +1,210 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; + +/** + * Account Type Creation — the legacy DDS screen this replaces, field for field: group, code, + * description, and whether interest posts automatically and how often. Nothing else: DDS never used + * the deposit-product engine, so there are no terms, charges or accounting tabs behind this. + * + * The same form handles create and edit; an `accountTypeId` in the route switches it to edit. + */ +@Component({ + selector: 'mifosx-create-dds-account-type', + templateUrl: './create-dds-account-type.component.html', + styleUrls: ['./create-dds-account-type.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent + ] +}) +export class CreateDdsAccountTypeComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private productsService = inject(ProductsService); + private alertService = inject(AlertService); + private settingsService = inject(SettingsService); + + accountTypeGroups = ['DDS']; + + autoInterestPostWhenOptions = [ + 'Monthly', + 'Quarterly', + 'Half Yearly', + 'Yearly' + ]; + + /** Set when the route carries an id — the form is then editing rather than creating. */ + accountTypeId: number | null = null; + + accountTypeForm: UntypedFormGroup = this.formBuilder.group({ + accountTypeGroup: [ + 'DDS', + Validators.required + ], + accountTypeCode: [ + '', + Validators.required + ], + accountTypeDescription: [ + '', + Validators.required + ], + autoInterestPost: [ + true, + Validators.required + ], + autoInterestPostWhen: [ + 'Monthly', + Validators.required + ], + autoInterestPostDay: [''], + /* + * Defaults to No, so a scheme created without a thought about it keeps behaving the way every + * scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a + * decision somebody makes, not one they inherit. + */ + maturityAutoClose: [ + false, + Validators.required + ], + /* + * What this scheme charges when an account is closed before its term is up. Set here rather than + * asked of the clerk at closing time: the terms are the bank's, and the same for every account on + * the scheme. Defaults are the bank's stated terms - 12 months, 1% and 1%. + */ + prematureLockinMonths: [ + 12, + [ + Validators.required, + Validators.min(0), + Validators.max(600) + ] + ], + prematureInterestReduction: [ + 1, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + prematureAgentCommissionPenalty: [ + 1, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ] + }); + + get isEdit(): boolean { + return this.accountTypeId !== null; + } + + /** + * "Auto Interest Post Day" only means anything while auto-posting is on, and the legacy screen + * hid it the same way. "When" stays visible either way, as it did there. + */ + get autoPostOn(): boolean { + return !!this.accountTypeForm.get('autoInterestPost')?.value; + } + + ngOnInit(): void { + // The posting day is required exactly while auto-posting is on, and is cleared when it is off. + const dayControl = this.accountTypeForm.get('autoInterestPostDay'); + const applyDayRule = (autoPostOn: boolean) => { + if (autoPostOn) { + dayControl?.setValidators([Validators.required]); + } else { + dayControl?.clearValidators(); + dayControl?.setValue(''); + } + dayControl?.updateValueAndValidity(); + }; + applyDayRule(this.autoPostOn); + this.accountTypeForm.get('autoInterestPost')?.valueChanges.subscribe((value) => applyDayRule(!!value)); + + const routeId = this.route.snapshot.paramMap.get('accountTypeId'); + if (!routeId) { + return; + } + this.accountTypeId = Number(routeId); + this.productsService.getDdsAccountType(this.accountTypeId).subscribe((accountType: any) => { + this.accountTypeForm.patchValue({ + accountTypeGroup: accountType.accountTypeGroup, + accountTypeCode: accountType.accountTypeCode, + accountTypeDescription: accountType.accountTypeDescription, + autoInterestPost: !!accountType.autoInterestPost, + autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly', + autoInterestPostDay: accountType.autoInterestPostDay ?? '', + maturityAutoClose: !!accountType.maturityAutoClose, + // ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one + // that charges nothing - and must not be replaced by the default. + prematureLockinMonths: accountType.prematureLockinMonths ?? 12, + prematureInterestReduction: accountType.prematureInterestReduction ?? 1, + prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1 + }); + }); + } + + cancel(): void { + this.backToList(); + } + + submit(): void { + if (this.accountTypeForm.invalid) { + return; + } + const raw = this.accountTypeForm.getRawValue(); + const payload = { + ...raw, + // The posting day only applies while auto-posting is on; send nothing for it otherwise. + autoInterestPostDay: raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined, + // Sent as numbers, not the strings a text input yields - the backend reads them as decimals. + prematureLockinMonths: Number(raw.prematureLockinMonths), + prematureInterestReduction: Number(raw.prematureInterestReduction), + prematureAgentCommissionPenalty: Number(raw.prematureAgentCommissionPenalty), + // Numeric parameters have to carry the locale they were entered in. + locale: this.settingsService.language.code + }; + + const done = () => { + this.alertService.alert({ + type: 'Success', + message: this.isEdit + ? `Account type ${payload.accountTypeCode} updated` + : `Account type ${payload.accountTypeCode} created` + }); + this.backToList(); + }; + + if (this.accountTypeId !== null) { + this.productsService.updateDdsAccountType(this.accountTypeId, payload).subscribe(done); + } else { + this.productsService.createDdsAccountType(payload).subscribe(done); + } + } + + private backToList(): void { + this.router.navigate(['/products/dds-account-types']); + } +} diff --git a/src/app/products/dds-account-types/dds-account-types.component.html b/src/app/products/dds-account-types/dds-account-types.component.html new file mode 100644 index 000000000..5fbea59c9 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.html @@ -0,0 +1,141 @@ + + +
+
+
+
+
+

DDS Account Types

+

Scheme variants a Daily Deposit account can be opened under.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'type' : 'types' }} +
+ +
+
+ +
+
+
+
+
{{ totalCount | number }}
+
Total Account Types
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No account types yet

+

Create one so Daily Deposit accounts can be opened against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
A/c Type +
+
+
+
{{ t.accountTypeCode }}
+
{{ t.accountTypeGroup }}
+
+
+
Description{{ t.accountTypeDescription }}Auto Interest Post + @if (t.autoInterestPost) { + {{ t.autoInterestPostWhen || 'Yes' }} + @if (t.autoInterestPostDay) { + day {{ t.autoInterestPostDay }} + } + } @else { + No + } + Created + {{ t.createdDate ? (t.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + Actions + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No account types match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/products/dds-account-types/dds-account-types.component.scss b/src/app/products/dds-account-types/dds-account-types.component.scss new file mode 100644 index 000000000..9d6517e68 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.scss @@ -0,0 +1,39 @@ +/** + * 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/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* DDS-specific teal accent */ +:host { + --pl-accent: #0f766e; + --pl-accent-l: #ccfbf1; +} + +/* A retired account type stays listed but reads as switched off. */ +.dat-row--inactive td:not(.num) { + opacity: 0.55; +} + +.dat-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.dat-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} diff --git a/src/app/products/dds-account-types/dds-account-types.component.ts b/src/app/products/dds-account-types/dds-account-types.component.ts new file mode 100644 index 000000000..9b3e13e57 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.ts @@ -0,0 +1,112 @@ +/** + * 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, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; + +/** + * DDS Account Types list. + * + * The scheme variants a Party account can be opened under - the legacy "Account Type Creation" + * record. Master data only, deliberately not a deposit product: DDS never used the deposit-product + * engine, and interest is accrued off each Party's own rate by the DDS batch job. + * + * Follows the same list -> create/edit shape as every other product list in this section, and + * shares their stylesheet. + */ +@Component({ + selector: 'mifosx-dds-account-types', + templateUrl: './dds-account-types.component.html', + styleUrls: ['./dds-account-types.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class DdsAccountTypesComponent implements OnInit { + private productsService = inject(ProductsService); + + displayedColumns: string[] = [ + 'accountTypeCode', + 'accountTypeDescription', + 'autoInterestPost', + 'createdDate', + 'actions' + ]; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + accountTypes: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.accountTypes.length; + } + + ngOnInit(): void { + this.loadAccountTypes(); + } + + private loadAccountTypes(): void { + this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => { + this.accountTypes = accountTypes || []; + this.dataSource = new MatTableDataSource(this.accountTypes); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.accountTypes.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * Retires or restores an account type. Never a delete: Parties are opened against an account + * type, so removing it would orphan every account already using it. Deactivating takes it out of + * the pick lists for new accounts and leaves existing ones untouched. + */ +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index 9c0111bef..86c35731d 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -30,12 +30,8 @@ import { EditShareProductComponent } from './share-products/edit-share-product/e import { ManageTaxConfigurationsComponent } from './manage-tax-configurations/manage-tax-configurations.component'; import { RecurringDepositProductsComponent } from './recurring-deposit-products/recurring-deposit-products.component'; import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component'; -import { DailyDepositSchemeProductsComponent } from './daily-deposit-scheme-products/daily-deposit-scheme-products.component'; -import { CreateDailyDepositSchemeProductComponent } from './daily-deposit-scheme-products/create-daily-deposit-scheme-product/create-daily-deposit-scheme-product.component'; -import { ViewDailyDepositSchemeProductComponent } from './daily-deposit-scheme-products/view-daily-deposit-scheme-product/view-daily-deposit-scheme-product.component'; -import { DailyDepositSchemeGeneralTabComponent } from './daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-general-tab/daily-deposit-scheme-general-tab.component'; -import { DailyDepositSchemeDatatableTabComponent } from './daily-deposit-scheme-products/view-daily-deposit-scheme-product/daily-deposit-scheme-datatable-tab/daily-deposit-scheme-datatable-tab.component'; -import { EditDailyDepositSchemeProductComponent } from './daily-deposit-scheme-products/edit-daily-deposit-scheme-product/edit-daily-deposit-scheme-product.component'; +import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component'; +import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component'; import { ChargesComponent } from './charges/charges.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; @@ -81,10 +77,6 @@ import { ShareProductResolver } from './share-products/share-product-resolver'; import { ShareProductAndTemplateResolver } from './share-products/edit-share-product/share-product-and-template.resolver'; import { RecurringDepositProductsResolver } from './recurring-deposit-products/recurring-deposit-products.resolver'; import { RecurringDepositProductResolver } from './recurring-deposit-products/recurring-deposit-product.resolver'; -import { DailyDepositSchemeProductsResolver } from './daily-deposit-scheme-products/daily-deposit-scheme-products.resolver'; -import { DailyDepositSchemeProductsTemplateResolver } from './daily-deposit-scheme-products/create-daily-deposit-scheme-product/daily-deposit-scheme-products-template.resolver'; -import { DailyDepositSchemeProductResolver } from './daily-deposit-scheme-products/daily-deposit-scheme-product.resolver'; -import { DailyDepositSchemeProductAndTemplateResolver } from './daily-deposit-scheme-products/edit-daily-deposit-scheme-product/daily-deposit-scheme-product-and-template.resolver'; import { ChargesResolver } from './charges/charges.resolver'; import { ChargeResolver } from './charges/charge.resolver'; import { ChargesTemplateResolver } from './charges/charges-template.resolver'; @@ -735,79 +727,22 @@ const routes: Routes = [ ] }, { - path: 'daily-deposit-scheme-products', - data: { title: 'Daily Deposit Scheme Products', breadcrumb: 'Daily Deposit Scheme Products' }, + path: 'dds-account-types', + data: { title: 'DDS Account Types', breadcrumb: 'DDS Account Types' }, children: [ - { - path: 'create', - component: CreateDailyDepositSchemeProductComponent, - data: { title: 'Create Daily Deposit Scheme Product', breadcrumb: 'Create' }, - resolve: { - dailyDepositSchemeProductsTemplate: DailyDepositSchemeProductsTemplateResolver - } - }, { path: '', - component: DailyDepositSchemeProductsComponent, - resolve: { - dailyDepositSchemeProducts: DailyDepositSchemeProductsResolver - } + component: DdsAccountTypesComponent }, { - path: ':productId', - data: { - title: 'View Daily Deposit Scheme Product', - breadcrumb: 'productId', - routeParamBreadcrumb: 'productId' - }, - resolve: { - recurringDepositDatatables: SavingProductDatatablesResolver - }, - children: [ - { - path: '', - component: ViewDailyDepositSchemeProductComponent, - children: [ - { - path: '', - redirectTo: 'general', - pathMatch: 'full' - }, - { - path: 'general', - component: DailyDepositSchemeGeneralTabComponent, - resolve: { - dailyDepositSchemeProduct: DailyDepositSchemeProductResolver - } - }, - { - path: 'datatables', - children: [ - { - path: ':datatableName', - component: DailyDepositSchemeDatatableTabComponent, - data: { title: 'Data Table View', routeParamBreadcrumb: 'datatableName' }, - resolve: { - recurringDepositDatatable: SavingProductDatatableResolver - } - } - ] - } - ] - }, - { - path: 'edit', - data: { - title: 'Edit Daily Deposit Scheme Product', - breadcrumb: 'edit', - routeParamBreadcrumb: false - }, - component: EditDailyDepositSchemeProductComponent, - resolve: { - DailyDepositSchemeProductAndTemplate: DailyDepositSchemeProductAndTemplateResolver - } - } - ] + path: 'create', + component: CreateDdsAccountTypeComponent, + data: { title: 'Create Account Type', breadcrumb: 'Create' } + }, + { + path: ':accountTypeId/edit', + component: CreateDdsAccountTypeComponent, + data: { title: 'Edit Account Type', breadcrumb: 'Edit', routeParamBreadcrumb: false } } ] }, @@ -1091,10 +1026,6 @@ const routes: Routes = [ ShareProductAndTemplateResolver, RecurringDepositProductsResolver, RecurringDepositProductResolver, - DailyDepositSchemeProductsResolver, - DailyDepositSchemeProductsTemplateResolver, - DailyDepositSchemeProductResolver, - DailyDepositSchemeProductAndTemplateResolver, ChargesResolver, ChargeResolver, ChargesTemplateAndResolver, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index 07d63d8db..aa9618577 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -125,20 +125,14 @@
-
- +
+
-

{{ 'labels.heading.Daily Deposit Scheme Products' | translate }}

-

- {{ 'labels.text.Add, modify or inactivate a Daily Deposit Scheme product' | translate }} -

+

DDS Account Types

+

Create a Daily Deposit Scheme account type — group, code and auto-interest posting

diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index c986955bc..f7b29aa21 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -235,6 +235,43 @@ export class ProductsService { return this.http.get('/recurringdepositproducts/template'); } + /** + * DDS Account Types — the legacy "Account Type Creation" master (group / code / description / + * auto-interest-post). Master data only, deliberately not a deposit product: DDS accrues + * interest off each Party's own rate, so there is no product engine behind this. + * + * @returns {Observable} DDS account types + */ + getDdsAccountTypes(): Observable { + return this.http.get('/dds/accounttypes'); + } + + /** + * @param {string} accountTypeId DDS account type id + * @returns {Observable} A single DDS account type + */ + getDdsAccountType(accountTypeId: string | number): Observable { + return this.http.get(`/dds/accounttypes/${accountTypeId}`); + } + + /** + * @param {any} accountType DDS account type to create + * @returns {Observable} + */ + createDdsAccountType(accountType: any): Observable { + return this.http.post('/dds/accounttypes', accountType); + } + + /** + * @param {string} accountTypeId DDS account type id + * @param {any} accountType Changes to apply + * @returns {Observable} + */ + updateDdsAccountType(accountTypeId: string | number, accountType: any): Observable { + return this.http.put(`/dds/accounttypes/${accountTypeId}`, accountType); + } + + /** * @returns {Observable} Charges data. */ diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html index c4c147847..4128299e6 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html @@ -53,7 +53,18 @@ {{ 'labels.inputs.Value' | translate }} - {{ configuration.value }} + + {{ configuration.value }} + + @if (glAccountLabel(configuration); as glAccount) { + {{ glAccount }} + } + diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss index 4561ef8a6..9141035e4 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss @@ -9,3 +9,14 @@ table { width: 100%; } + +/* + * The ledger account a setting points at. Quieter than the value itself, because the id is what the + * setting actually holds and what gets typed when it is edited - the name is here to say what that + * id means, not to replace it. + */ +.gc-gl-name { + margin-left: 8px; + color: rgb(0 0 0 / 60%); + font-size: 0.82rem; +} diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts index 80a29ad45..a64e47121 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts @@ -29,6 +29,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; +import { AccountingService } from 'app/accounting/accounting.service'; import { PopoverService } from '../../../configuration-wizard/popover/popover.service'; import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -71,9 +72,22 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { private router = inject(Router); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); + private accountingService = inject(AccountingService); /** Configuration data. */ configurationData: any; + + /** + * The chart of accounts, so a setting that holds a ledger account's id can be shown by its name. + * + * Several settings store nothing but a number - "44" says which account the money moves through, + * and says it only to the database. An officer checking that the scheme is posting where it should + * would otherwise have to carry the number to the Chart of Accounts and look it up. + * + * Left empty if the list cannot be read - a user without permission on the chart of accounts still + * sees every setting and its value, just without the name beside it. + */ + private glAccountsById = new Map(); /** Columns to be displayed in configurations table. */ displayedColumns: string[] = [ 'name', @@ -119,6 +133,36 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { */ ngOnInit() { this.setConfigurationData(); + this.loadGlAccounts(); + } + + private loadGlAccounts(): void { + this.accountingService.getGlAccounts().subscribe({ + next: (accounts: any) => { + for (const account of accounts || []) { + this.glAccountsById.set(Number(account.id), account); + } + }, + // The settings are the point of this screen; a chart of accounts that will not load must not + // take them down with it. The values still show, only the names beside them are missing. + error: () => this.glAccountsById.clear() + }); + } + + /** + * The ledger account a setting points at, as "name (code)" - the same way the product screens show + * one. Empty for every setting that does not hold a ledger account id, which is most of them. + * + * Only settings whose name marks them as an account mapping are resolved. Reading every numeric + * setting as an account id would put a name against figures that are nothing of the sort - a + * retry count of 3 would come back as whatever account 3 happens to be. + */ + glAccountLabel(configuration: any): string { + if (!configuration?.name?.endsWith('-account')) { + return ''; + } + const account = this.glAccountsById.get(Number(configuration.value)); + return account ? `${account.name} (${account.glCode})` : ''; } /** diff --git a/src/environments/.env.ts b/src/environments/.env.ts index fd0d2a130..0f8cfa339 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260813', - 'hash': '5a6fb1f9' + 'version': '260824', + 'hash': '7ef75c90' }, 'allow_switching_backend_instance': true }; From 283ab2f733c74d1dbd07a7f7d213fe9f270d2734 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Tue, 29 Sep 2026 18:06:08 +0530 Subject: [PATCH 03/21] feat(loan-products): add advance loan product stepper steps Co-Authored-By: Claude Sonnet 5.5 --- .../create-loan-product.component.html | 125 +- .../create-loan-product.component.ts | 267 +- .../loan-product-stepper/advance-steps.scss | 174 ++ ...oduct-advance-interest-step.component.html | 120 + ...product-advance-interest-step.component.ts | 105 + ...t-advance-limit-tenure-step.component.html | 155 + ...uct-advance-limit-tenure-step.component.ts | 171 ++ ...an-product-cheque-book-step.component.html | 144 + ...loan-product-cheque-book-step.component.ts | 92 + .../loan-product-details-step.component.html | 340 +-- .../loan-product-details-step.component.scss | 51 + .../loan-product-details-step.component.ts | 202 +- ...loan-product-penal-npa-step.component.html | 106 + .../loan-product-penal-npa-step.component.ts | 90 + ...roduct-renewal-closure-step.component.html | 69 + ...-product-renewal-closure-step.component.ts | 64 + ...-product-security-lien-step.component.html | 160 ++ ...-product-security-lien-step.component.scss | 117 + ...an-product-security-lien-step.component.ts | 196 ++ .../loan-product-settings-step.component.html | 2525 +++++++++-------- .../loan-product-settings-step.component.scss | 10 + .../loan-product-settings-step.component.ts | 86 +- .../loan-product-terms-step.component.html | 56 + .../loan-product-terms-step.component.ts | 17 +- .../products/loan-products/loan-products.ts | 22 +- 25 files changed, 3934 insertions(+), 1530 deletions(-) create mode 100644 src/app/products/loan-products/loan-product-stepper/advance-steps.scss create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html index 260e066d8..abc3da5c8 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html @@ -42,19 +42,94 @@
- - - - {{ 'labels.inputs.SETTINGS' | translate }} - -
- -
-
+ @if (isAdvanceProduct) { + + + + SECURITY & LIEN + +
+ +
+
+ + + + + LIMIT & TENURE + +
+ +
+
+ + + + + INTEREST + +
+ +
+
+ + + + + PENAL & NPA + +
+ +
+
+ + + + + RENEWAL & CLOSURE + +
+ +
+
+ + @if (isOverdraftFacility) { + + + + CHEQUE BOOK + +
+ +
+
+ } + } + + @if (!isAdvanceProduct) { + + + + {{ 'labels.inputs.SETTINGS' | translate }} + +
+ +
+
+ } @if (isAdvancedPaymentStrategy) { @@ -90,15 +165,19 @@ } - - - - {{ 'labels.inputs.TERMS' | translate }} - -
- -
-
+ @if (!isAdvanceProduct) { + + + + {{ 'labels.inputs.TERMS' | translate }} + +
+ +
+
+ } @@ -109,7 +188,7 @@
diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 93785c36a..50c14db3b 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts @@ -13,6 +13,12 @@ import { ActivatedRoute, Router } from '@angular/router'; /** Custom Components */ import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component'; import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component'; +import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component'; +import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component'; +import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component'; +import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component'; +import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component'; +import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component'; import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component'; import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component'; import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; @@ -34,7 +40,7 @@ import { import { Accounting } from 'app/core/utils/accounting'; import { StringEnumOptionData } from '../../../shared/models/option-data.model'; import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component'; -import { UntypedFormGroup } from '@angular/forms'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component'; import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component'; @@ -51,6 +57,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FaIconComponent, LoanProductDetailsStepComponent, LoanProductCurrencyStepComponent, + LoanProductSecurityLienStepComponent, + LoanProductAdvanceLimitTenureStepComponent, + LoanProductAdvanceInterestStepComponent, + LoanProductPenalNpaStepComponent, + LoanProductRenewalClosureStepComponent, + LoanProductChequeBookStepComponent, LoanProductSettingsStepComponent, LoanProductInterestRefundStepComponent, LoanProductPaymentStrategyStepComponent, @@ -73,12 +85,27 @@ export class CreateLoanProductComponent implements OnInit { @ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent; @ViewChild(LoanProductCurrencyStepComponent, { static: true }) loanProductCurrencyStep: LoanProductCurrencyStepComponent; + // Conditional tabs (Advances A/C only): static false — resolved after the @if renders them. + @ViewChild(LoanProductSecurityLienStepComponent) + loanProductSecurityLienStep: LoanProductSecurityLienStepComponent; + @ViewChild(LoanProductAdvanceLimitTenureStepComponent) + loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent; + @ViewChild(LoanProductAdvanceInterestStepComponent) + loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent; + @ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent; + @ViewChild(LoanProductRenewalClosureStepComponent) + loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent; + @ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent; + + /** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */ + readonly multiDisburseFallback = new UntypedFormControl(false); @ViewChild(LoanProductInterestRefundStepComponent, { static: true }) loanProductInterestRefundStep: LoanProductInterestRefundStepComponent; @ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true }) loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent; - @ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent; - @ViewChild(LoanProductSettingsStepComponent, { static: true }) + // Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically. + @ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent; + @ViewChild(LoanProductSettingsStepComponent) loanProductSettingsStep: LoanProductSettingsStepComponent; @ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent; @ViewChild(LoanProductAccountingStepComponent, { static: true }) @@ -143,7 +170,18 @@ export class CreateLoanProductComponent implements OnInit { } get loanProductTermsForm() { - return this.loanProductTermsStep.loanProductTermsForm; + // Absent on the Advances path (tab hidden), so guard for callers. + return this.loanProductTermsStep?.loanProductTermsForm; + } + + /** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */ + get isOverdraftFacility(): boolean { + return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft; + } + + /** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */ + get advanceSubType(): string { + return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? ''; } advancePaymentStrategy(value: string) { @@ -231,47 +269,71 @@ export class CreateLoanProductComponent implements OnInit { } get loanProductSettingsForm() { - return this.loanProductSettingsStep.loanProductSettingsForm; + // Absent on the Advances path (tab hidden), so guard for callers. + return this.loanProductSettingsStep?.loanProductSettingsForm; } get loanProductAccountingForm() { return this.loanProductAccountingStep.loanProductAccountingForm; } + /** The Details step's chosen product type; drives the Advances-only tabs. */ + get isAdvanceProduct(): boolean { + return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; + } + get totalTabs(): number { - let tabs = 6; // details, currency, settings, terms, charges, accounting - if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + let tabs = 4; // details, currency, charges, accounting (always) + if (this.isAdvanceProduct) { + // security & lien, limit & tenure, interest, penal & npa, renewal & closure + tabs += 5; + if (this.isOverdraftFacility) tabs += 1; // cheque book + } else { + tabs += 2; // settings, terms + if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + } if (this.loanProductFormValid) tabs += 1; // preview return tabs; } get loanProductFormValid() { - if (this.isAdvancedPaymentStrategy) { + const base = + this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid; + + // Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden. + if (this.isAdvanceProduct) { return ( - this.loanProductDetailsForm.valid && - this.loanProductCurrencyForm.valid && - this.loanProductTermsForm.valid && - this.loanProductSettingsForm.valid && - this.loanIncomeCapitalizationForm.valid && - this.loanProductAccountingForm.valid - ); - } else { - return ( - this.loanProductDetailsForm.valid && - this.loanProductCurrencyForm.valid && - this.loanProductTermsForm.valid && - this.loanProductSettingsForm.valid && - this.loanProductAccountingForm.valid + base && + !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid && + !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid && + !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid && + !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && + !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid && + (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid) ); } + + // Loans A/C: original Settings + Terms path. + const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; + if (this.isAdvancedPaymentStrategy) { + return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + } + return base && loansValid; } get loanProduct() { + // Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields + // (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params. + if (this.isAdvanceProduct) { + return this.assembleAdvanceProduct(); + } + + // Loans A/C: original composition (Settings/Terms present). const loanProduct = { ...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductCurrencyStep.loanProductCurrency, - ...this.loanProductTermsStep.loanProductTerms, - ...this.loanProductSettingsStep.loanProductSettings, + ...(this.loanProductTermsStep?.loanProductTerms ?? {}), + ...(this.loanProductSettingsStep?.loanProductSettings ?? {}), ...this.loanProductChargesStep.loanProductCharges, ...this.loanProductAccountingStep.loanProductAccounting }; @@ -304,6 +366,163 @@ export class CreateLoanProductComponent implements OnInit { return loanProduct; } + /** + * Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's + * mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by + * LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be + * rejected by checkForUnsupportedParameters. + */ + private assembleAdvanceProduct(): any { + const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v)); + + const d = this.loanProductDetailsStep.loanProductDetails; + const cur = this.loanProductCurrencyStep.loanProductCurrency; + const sec = this.loanProductSecurityLienStep.loanProductSecurityLien; + const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure; + const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest; + const pn = this.loanProductPenalNpaStep.loanProductPenalNpa; + const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure; + const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null; + + const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code; + const minTenure = num(lt.minTenure); + const minRate = num(intr.minRate); + + const payload: any = { + // identity + currency + name: d.name, + shortName: d.shortName, + description: d.description, + startDate: d.startDate, + closeDate: d.closeDate, + currencyCode: cur.currencyCode, + digitsAfterDecimal: cur.digitsAfterDecimal, + inMultiplesOf: 1, + + // limit -> principal + minPrincipal: num(lt.minLimit), + principal: num(lt.defaultLimit) ?? num(lt.minLimit), + maxPrincipal: num(lt.maxLimit), + + // tenure -> repayments (monthly) + minNumberOfRepayments: minTenure, + numberOfRepayments: num(lt.defaultTenure) ?? minTenure, + maxNumberOfRepayments: num(lt.maxTenure), + repaymentEvery: 1, + repaymentFrequencyType: 2, // months + + // rate + minInterestRatePerPeriod: minRate, + interestRatePerPeriod: minRate, + maxInterestRatePerPeriod: num(intr.maxRate), + interestRateFrequencyType: 3, // per year + + // Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer) + amortizationType: 1, // equal installments + interestType: 0, // declining balance + interestCalculationPeriodType: 1, // same as repayment period + transactionProcessingStrategyCode: defaultStrategy, + daysInYearType: 1, // actual + daysInMonthType: 1, // actual + isInterestRecalculationEnabled: false, + + // NPA / delinquency map onto core product fields + overdueDaysForNPA: num(pn.npaDays), + graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing), + inArrearsTolerance: num(pn.inArrearsTolerance), + delinquencyBucketId: pn.delinquencyBucketId || undefined, + + // accounting rule + GL mappings + charges (all standard, whitelisted) + ...this.loanProductAccountingStep.loanProductAccounting, + ...this.loanProductChargesStep.loanProductCharges, + + // ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ---- + productTypeGroup: d.productTypeGroup, + productTypeSubGroup: d.productTypeSubGroup, + glAccount: d.glAccount || undefined, + loanProductCategory: d.loanProductCategory, + prioritySector: d.prioritySector, + cibilAccountType: d.cibilAccountType, + kfsEnabled: d.kfsEnabled, + aprEnabled: d.aprEnabled, + facilityType: lt.facilityType, + securityType: sec.securityType, + eligibleAmountBasis: sec.eligibleAmountBasis, + margin: num(sec.margin), + ltv: sec.ltv, + dpMethod: 'security_value_x_ltv', + lienRequired: this.loanProductSecurityLienStep.isLienSubType, + lienMarkingStage: sec.lienMarkingStage, + lienCertificateRequired: sec.lienCertificateRequired, + lienCertificatePrefix: sec.lienCertificatePrefix, + printCertificateOnDisbursement: sec.printOnDisbursement, + interestBasis: intr.interestBasis, + spread: num(intr.spread), + interestChargedOn: intr.interestChargedOn, + interestPostingFrequency: intr.postingFrequency, + penalApplicable: pn.penalApplicable, + penalRate: num(pn.penalRate), + penalAppliedOn: pn.penalAppliedOn, + penalGraceDays: num(pn.penalGraceDays), + npaTrigger: pn.npaTrigger, + interestAfterNpa: pn.interestAfterNpa, + renewalAllowed: rc.renewalAllowed, + renewalPeriodMonths: num(rc.renewalPeriodMonths), + renewalGraceDays: num(rc.renewalGraceDays), + preclosureAllowed: rc.preclosureAllowed, + lockInMonths: num(rc.lockInMonths), + interestOnPreclosure: rc.interestOnPreclosure, + chequeBookEnabled: this.isOverdraftFacility, + chequeType: cb?.chequeType, + defaultChequeBookSize: num(cb?.defaultBookSize), + ppsThreshold: num(cb?.ppsThreshold), + ppsMandatoryAbove: num(cb?.ppsMandatoryAbove), + + // ---- remaining wizard fields ---- + // security & lien extras (deposit list joined; rule checkboxes) + eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'), + lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity, + lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure, + lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits, + lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity, + lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit, + lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien, + // limit & tenure extras + repaymentType: lt.repaymentType, + appropriationOrder: lt.appropriationOrder, + debitBalanceAllow: lt.debitBalanceAllow, + allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate, + branchLevelAccountType: lt.branchLevelAccountType, + // interest extras + interestCalcMethod: intr.calcMethod, + interestOnCreditBalance: intr.interestOnCreditBalance, + interestPostingDay: intr.postingDay, + // NPA upgrade rules + npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, + npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, + // cheque book full config + chequeBookSizes: (cb?.bookSizes ?? []).join('|'), + chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding), + chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves), + chequeFirstBookFree: cb?.firstBookFree, + chequeFreeBooksPerYear: num(cb?.freeBooksPerYear), + chequeNamePrinted: cb?.namePrinted, + chequePrintDescription: cb?.chequePrintDescription, + chequeStopPaymentAllowed: cb?.stopPaymentAllowed, + chequeMaintainStockRegister: cb?.maintainStockRegister, + chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip, + chequeBlockNpa: cb?.blockNpa, + chequeBlockLimitExpired: cb?.blockLimitExpired, + chequeBlockOverDp: cb?.blockOverDp, + chequeBlockFrozen: cb?.blockFrozen, + chequeBlockLienReleased: cb?.blockLienReleased + }; + + // Drop undefined keys so we never send an unresolved value. + Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); + return payload; + } + submit() { const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault); if (this.isAdvancedPaymentStrategy) { diff --git a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss new file mode 100644 index 000000000..29d9a6b7e --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss @@ -0,0 +1,174 @@ +/** + * 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/. + */ + +/* + * Shared styling for the Advances A/C wizard steps (Security & Lien, Limit & Tenure, + * Interest, Penal & NPA, Renewal & Closure, Cheque Book). + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.adv-section { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } + + &__hint { + margin-left: auto; + font-size: 12px; + color: #5f6368; + } +} + +.adv-subsection { + padding: 16px 24px; + + & + & { + border-top: 1px solid $border; + } +} + +.adv-group { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #5f6368; +} + +.adv-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-grid--3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 900px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-subsection .adv-grid { + padding: 0; +} + +.adv-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.adv-checks { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 32px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-type-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 16px 24px 0; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-type-option { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 10px 14px; + + &__label { + display: block; + font-weight: 600; + color: $gray-900; + } + + &__hint { + display: block; + font-size: 12px; + color: #5f6368; + } +} + +.adv-note { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 16px; + padding: 12px 24px; + border: 1px dashed $border; + border-radius: 10px; + font-size: 13px; + color: #5f6368; + + i { + color: $accent; + margin-top: 2px; + } +} + +.adv-info { + padding: 12px 16px; + border-radius: 8px; + background: #f0f9ff; + border: 1px solid #bae6fd; + font-size: 13px; + color: #075985; + line-height: 1.5; +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html new file mode 100644 index 000000000..d81f96fbf --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html @@ -0,0 +1,120 @@ + + +
+
+
+ +

Rate

+
+
+ + Interest Basis + + @for (opt of interestBasisOptions; track opt.id) { + {{ opt.label }} + } + + + @if (showSpread) { + + Spread over Deposit Rate (%) + + Advance rate = deposit rate + spread + + } @else { +
+ } +
+ + Minimum Rate (% p.a.) + + Minimum Rate is required + + + Maximum Rate (% p.a.) + + Maximum Rate is required + +
+
+ +
+
+ +

Calculation

+
+
+ + Calculation Method + + @for (opt of calcMethodOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Year + + @for (opt of daysInYearOptions; track opt) { + {{ opt }} + } + + + + Days in Month + + @for (opt of daysInMonthOptions; track opt) { + {{ opt }} + } + + + + Interest Charged On + + @for (opt of chargedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest on Credit Balance + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+
+ +

Posting

+
+
+ + Posting Frequency + + @for (opt of postingFrequencyOptions; track opt) { + {{ opt }} + } + + + + Posting Day + + @for (opt of postingDayOptions; track opt) { + {{ opt }} + } + + +
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts new file mode 100644 index 000000000..89dbb9956 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts @@ -0,0 +1,105 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds, + * daily calculation and monthly posting. UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-advance-interest-step', + templateUrl: './loan-product-advance-interest-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class LoanProductAdvanceInterestStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductAdvanceInterestForm: UntypedFormGroup; + + readonly interestBasisOptions = [ + { id: 'deposit_spread', label: 'Deposit Rate + Spread' }, + { id: 'fixed', label: 'Fixed rate' }, + { id: 'floating', label: 'Floating (EBLR-linked)' } + ]; + + readonly calcMethodOptions = [ + { id: 'daily', label: 'Daily product (daily outstanding)' }, + { id: 'monthly', label: 'Monthly average balance' } + ]; + + readonly daysInYearOptions = [ + 'Actual (365 / 366)', + '365', + '360' + ]; + readonly daysInMonthOptions = [ + 'Actual', + '30' + ]; + + readonly chargedOnOptions = [ + { id: 'utilised', label: 'Utilised (debit) balance' }, + { id: 'limit', label: 'Sanctioned limit' } + ]; + + readonly postingFrequencyOptions = [ + 'Monthly', + 'Quarterly', + 'Half-yearly', + 'At Maturity' + ]; + readonly postingDayOptions = [ + 'Last day of month', + '1st of month', + 'Anniversary day' + ]; + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.loanProductAdvanceInterestForm = this.formBuilder.group({ + interestBasis: ['deposit_spread'], + spread: [ + 2.0, + [ + Validators.min(0), + Validators.max(20) + ] + ], + minRate: [ + '', + Validators.required + ], + maxRate: [ + '', + Validators.required + ], + calcMethod: ['daily'], + daysInYear: ['Actual (365 / 366)'], + daysInMonth: ['Actual'], + interestChargedOn: ['utilised'], + interestOnCreditBalance: ['NO'], + postingFrequency: ['Monthly'], + postingDay: ['Last day of month'] + }); + } + + get showSpread(): boolean { + return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread'; + } + + get loanProductAdvanceInterest() { + return this.loanProductAdvanceInterestForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html new file mode 100644 index 000000000..ed9f6e540 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html @@ -0,0 +1,155 @@ + + +
+
+
+ +

Facility Type

+
+ + + Demand Loan + Paid out once; repaid at maturity. No cheque book. + + + Overdraft (Running) + Draw and repay any time within the limit. Cheque book allowed. + + +
+
+ This choice decides whether the Cheque Book step appears. CC / OD sub types are always running. +
+
+
+ +
+
+ +

Limit

+
+
+ + Minimum Limit (₹) + + Minimum Limit is required + + + Default Limit (₹) + + + + Maximum Limit (₹) + + Maximum Limit is required + +
+
+ +
+
+ +

Tenure & Repayment

+
+
+ + Minimum Tenure (months) + + @if (isFdTenureLocked) { + FD policy: 12–15 months only + } + + @if (isFdTenureLocked) { + Must be between 12 and 15 months + } @else { + Minimum Tenure is required + } + + + + Default Tenure (months) + + @if (isFdTenureLocked) { + Between 12 and 15 + Must be between 12 and 15 months + } + + + Maximum Tenure (months) + + @if (isFdTenureLocked) { + FD policy: max 15 months (also capped at deposit maturity) + } @else { + Also capped at the deposit's maturity (Lien rule) + } + + @if (isFdTenureLocked) { + Must be between 12 and 15 months + } @else { + Maximum Tenure is required + } + + + + Repayment Type + + @for (opt of repaymentTypeOptions; track opt.id) { + {{ opt.label }} + } + + + + Appropriation Order + + @for (opt of appropriationOrderOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Account Behaviour

+
+
+ + Debit Balance Allow + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Allow Debit Trans After Due Date + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Branch Level A/c Type + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+ + EMI fields (Number of Repayments, Repaid Every, Variable Installments) are hidden for an Advance. +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts new file mode 100644 index 000000000..b35557303 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts @@ -0,0 +1,171 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, Input } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Limit & Tenure step for an Advances A/C product (Figma step 4). + * + * Facility Type here decides the rest of the flow: an Overdraft (running) facility gets a cheque book, + * a Demand Loan does not. UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-advance-limit-tenure-step', + templateUrl: './loan-product-advance-limit-tenure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductAdvanceLimitTenureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductAdvanceLimitTenureForm: UntypedFormGroup; + + /** + * Product sub type picked on the Details step. "Loan against FD" is bank-policy capped to a + * 12–15 month tenure, so the officer cannot create an FD product outside that band. Other + * advance sub types (Gold, RD, CC, …) stay open. + */ + private _productSubType = ''; + @Input() + set productSubType(value: string) { + const next = value || ''; + if (next === this._productSubType) { + return; + } + this._productSubType = next; + this.applyTenurePolicy(); + } + get productSubType(): string { + return this._productSubType; + } + + readonly repaymentTypeOptions = [ + { id: 'bullet', label: 'Bullet – principal + interest at maturity' }, + { id: 'running', label: 'Running – any time; clear by limit expiry' } + ]; + + readonly appropriationOrderOptions = [ + { id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' }, + { id: 'penal_interest_principal_charges', label: 'Penal → Interest → Principal → Charges' } + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.loanProductAdvanceLimitTenureForm = this.formBuilder.group({ + facilityType: ['demand'], + minLimit: [ + '', + Validators.required + ], + defaultLimit: [''], + maxLimit: [ + '', + Validators.required + ], + minTenure: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + defaultTenure: [12], + maxTenure: [ + 15, + [ + Validators.required, + Validators.min(1) + ] + ], + repaymentType: ['bullet'], + appropriationOrder: ['charges_penal_interest_principal'], + debitBalanceAllow: ['YES'], + allowDebitTransAfterDueDate: ['NO'], + branchLevelAccountType: ['NO'] + }); + } + + /** Read by the wizard to decide whether the Cheque Book step applies. */ + get isOverdraft(): boolean { + return this.loanProductAdvanceLimitTenureForm.get('facilityType').value === 'overdraft'; + } + + get loanProductAdvanceLimitTenure() { + return this.loanProductAdvanceLimitTenureForm.getRawValue(); + } + + /** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */ + get isFdTenureLocked(): boolean { + return this._productSubType === 'fd'; + } + + get tenureMin(): number { + return this.isFdTenureLocked ? 12 : 1; + } + + get tenureMax(): number | null { + return this.isFdTenureLocked ? 15 : null; + } + + /** + * FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1). + * Called whenever the sub type changes so the validators — and thus the wizard's validity gate — + * match the selected product. + */ + private applyTenurePolicy(): void { + const form = this.loanProductAdvanceLimitTenureForm; + if (!form) { + return; + } + const min = form.get('minTenure'); + const def = form.get('defaultTenure'); + const max = form.get('maxTenure'); + if (this._productSubType === 'fd') { + const bounds = [ + Validators.min(12), + Validators.max(15) + ]; + min.setValidators([ + Validators.required, + ...bounds + ]); + def.setValidators(bounds); + max.setValidators([ + Validators.required, + ...bounds + ]); + min.setValue(12, { emitEvent: false }); + def.setValue(12, { emitEvent: false }); + max.setValue(15, { emitEvent: false }); + } else { + min.setValidators([ + Validators.required, + Validators.min(1) + ]); + def.setValidators([Validators.min(1)]); + max.setValidators([ + Validators.required, + Validators.min(1) + ]); + } + min.updateValueAndValidity({ emitEvent: false }); + def.updateValueAndValidity({ emitEvent: false }); + max.updateValueAndValidity({ emitEvent: false }); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html new file mode 100644 index 000000000..cbac09492 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html @@ -0,0 +1,144 @@ + + +
+
+
+ +

Cheque Book Facility

+ Overdraft (running) only +
+
+ + Cheque Type + + @for (opt of chequeTypeOptions; track opt) { + {{ opt }} + } + + + + Book Sizes Allowed (leaves) + + @for (opt of bookSizeOptions; track opt) { + {{ opt }} + } + + + + Default Book Size (leaves) + + @for (opt of bookSizeOptions; track opt) { + {{ opt }} + } + + + + Max Books Outstanding + + + + Reissue When Unused Leaves ≤ + + + + First Cheque Book Free + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Free Cheque Books per Year + + + + Name Printed on Cheque + + @for (opt of namePrintedOptions; track opt) { + {{ opt }} + } + + + + Cheque Print Description + + +
+
+ +
+
+ +

Validation & Security

+
+
+ + Positive Pay (PPS) Threshold (₹) + + Issuer confirms cheques at or above this + + + PPS Mandatory Above (₹) + + + + Cheque Validity + + + + Stop Payment Allowed + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Maintain Cheque Stock Register + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Allow Withdrawal Slip + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+
+ +

Block Cheque Issue & Clearing When

+
+
+
+ Account is NPA or out of order + Limit has expired (renewal due) + Debit would exceed the limit / drawing power + Account is frozen or inoperative + + Pledged deposit has matured or its lien was released + +
+
+
+ +
+ + Cheque book fees (issue, per leaf, return, stop payment) are set on the Charges step. +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts new file mode 100644 index 000000000..3eade52c6 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts @@ -0,0 +1,92 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Cheque Book step (Figma step 8), shown only when the Facility Type is Overdraft (running). + * UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-cheque-book-step', + templateUrl: './loan-product-cheque-book-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductChequeBookStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductChequeBookForm: UntypedFormGroup; + + readonly chequeTypeOptions = [ + 'CTS-2010 · Payable at par (multi-city)', + 'CTS-2010 · Local', + 'Non-CTS' + ]; + readonly bookSizeOptions = [ + '10', + '25', + '50', + '100' + ]; + readonly namePrintedOptions = [ + 'Account holder name', + 'Firm / entity name', + 'As per instruction' + ]; + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.loanProductChequeBookForm = this.formBuilder.group({ + chequeType: ['CTS-2010 · Payable at par (multi-city)'], + bookSizes: [ + [ + '10', + '25', + '50' + ] + ], + defaultBookSize: ['25'], + maxBooksOutstanding: [1], + reissueWhenLeaves: [5], + firstBookFree: ['YES'], + freeBooksPerYear: [1], + namePrinted: ['Account holder name'], + chequePrintDescription: [''], + ppsThreshold: [ + 50000, + [ + Validators.required, + Validators.min(0) + ] + ], + ppsMandatoryAbove: [500000], + stopPaymentAllowed: ['YES'], + maintainStockRegister: ['YES'], + allowWithdrawalSlip: ['NO'], + blockNpa: [true], + blockLimitExpired: [true], + blockOverDp: [true], + blockFrozen: [true], + blockLienReleased: [true] + }); + } + + get loanProductChequeBook() { + return this.loanProductChequeBookForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html index 49df9f6d2..62b792538 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html @@ -7,178 +7,190 @@ -->
-
-
- -

{{ 'labels.inputs.Product Name' | translate }} & {{ 'labels.inputs.Description' | translate }}

-
-
- - {{ 'labels.inputs.Product Name' | translate }} - - - {{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Short Name' | translate }} - - - {{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - -
-
- -
-
- -

{{ 'labels.inputs.Availability' | translate }}

-
-
- - {{ 'labels.inputs.Start Date' | translate }} - - - - - - - {{ 'labels.inputs.Close Date' | translate }} - - - - - - - {{ 'labels.inputs.Description' | translate }} - - -
-
- - -
-
- -

Interest Information

-
-
- - Debit Int. Calculation - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Separate Penal Int. - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Credit Int. Calculation - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Sub A/c Type Balance - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Final Int. Rounding - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - @if (loanProductDetailsForm.get('finalIntRounding')?.value) { - - Rounding UpTo - - - } -
-
- - +
-

Product Classification

+

Product Type

-
- - Product Type Group - - @for (group of productTypeGroups; track group.id) { - {{ group.label }} - } - - + + @for (group of productTypeGroups; track group.id) { + + {{ group.label }} + {{ group.hint }} + + } + - @if (loanProductDetailsForm.get('productTypeGroup')?.value) { + @if (loanProductDetailsForm.get('productTypeGroup')?.value) { +
- GL Account ({{ glClassificationLabel }}) - - @for (acc of availableGlAccounts; track acc.id) { - ({{ acc.glCode }}) {{ acc.name }} + Product Sub Type + + @for (subGroup of availableSubGroups; track subGroup.id) { + {{ subGroup.label }} } - GL Account is required + Product Sub Type is required - } -
+ + + GL Account (Asset) + + @for (acc of assetAccountOptions; track acc.id) { + ({{ acc.glCode }}) {{ acc.name }} + } + + GL Account is required + +
+ }
+ + @if (!isProductTypeChosen) { +
+ + Select a Product Type and Sub Type to continue. +
+ } + + @if (isProductTypeChosen) { +
+
+ +

{{ 'labels.inputs.Product Name' | translate }} & {{ 'labels.inputs.Description' | translate }}

+
+
+ + {{ 'labels.inputs.Product Name' | translate }} + + + {{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Short Name' | translate }} + + @if (loanProductDetailsForm.get('shortName').hasError('pattern')) { + Short Name must be exactly 2 capital letters (A–Z) + } @else { + + {{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Start Date' | translate }} + + + + + + + {{ 'labels.inputs.Close Date' | translate }} + + + + + + + {{ 'labels.inputs.Description' | translate }} + + +
+
+ +
+
+ +

Regulatory Classification

+
+
+ + Loan Product Category (RBI) + + @for (opt of loanProductCategoryOptions; track opt.id) { + {{ opt.label }} + } + + + + + Priority Sector + + @for (opt of prioritySectorOptions; track opt.id) { + {{ opt.label }} + } + + + + + CIBIL Account Type + + @for (opt of cibilAccountTypeOptions; track opt.id) { + {{ opt.label }} + } + + + + + Key Fact Statement (KFS) + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + + APR Calculation + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ }
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss index f6a3f62d5..a5eecb505 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss @@ -61,6 +61,57 @@ $gray-900: #202124; &--full { grid-column: 1 / -1; } + + // Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name. + &--row-start { + grid-column-start: 1; + } +} + +// Product Type — first section +.lpd-type-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 16px 24px 0; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.lpd-type-option { + border: 1px solid $border; + border-radius: 8px; + padding: 8px 12px; + + &__label { + display: block; + font-weight: 600; + color: $gray-900; + } + + &__hint { + display: block; + font-size: 12px; + color: #5f6368; + } +} + +.lpd-type-pending { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + padding: 12px 24px; + border: 1px dashed $border; + border-radius: 10px; + font-size: 13px; + color: #5f6368; + + i { + color: $accent; + } } .lpd-checkbox-row { diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts index ded56834c..6b32897fd 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts @@ -15,6 +15,7 @@ import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; import { MatTooltip } from '@angular/material/tooltip'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -24,7 +25,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatTooltip, - CdkTextareaAutosize + CdkTextareaAutosize, + MatRadioGroup, + MatRadioButton ] }) export class LoanProductDetailsStepComponent implements OnInit { @@ -50,6 +53,28 @@ export class LoanProductDetailsStepComponent implements OnInit { } ngOnInit() { + // Product Type is not persisted by the backend yet, so an existing product comes back without one. + // Only a new product is made to pick it first; editing must not hide or block the rest of the form. + this.isExistingProduct = !!this.loanProductsTemplate?.id; + if (!this.isExistingProduct) { + this.loanProductDetailsForm.get('productTypeGroup').setValidators(Validators.required); + this.loanProductDetailsForm.get('productTypeSubGroup').setValidators(Validators.required); + this.loanProductDetailsForm.get('glAccount').setValidators(Validators.required); + // A new product's short name is exactly two capital letters. Saved products keep the old 4-character + // rule, so an existing short name such as 'PW16' does not stop the product from being edited. + this.loanProductDetailsForm.get('shortName').setValidators([ + Validators.required, + Validators.pattern(/^[A-Z]{2}$/) + ]); + this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false }); + this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false }); + this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false }); + this.loanProductDetailsForm.get('shortName').updateValueAndValidity({ emitEvent: false }); + } + + // A loan / advance is an asset of the bank, so its ledger GL is always an asset account. + this.assetAccountOptions = this.loanProductsTemplate?.accountingMappingOptions?.assetAccountOptions ?? []; + this.loanProductDetailsForm.patchValue({ name: this.loanProductsTemplate.name, shortName: this.loanProductsTemplate.shortName, @@ -57,83 +82,119 @@ export class LoanProductDetailsStepComponent implements OnInit { startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate), closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate) }); - - const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {}; - this.assetAccountOptions = mappingOptions.assetAccountOptions ?? []; - this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? []; - - this.loanProductDetailsForm.patchValue({ - debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false, - separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false, - creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false, - subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false, - finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false, - roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null - }); } - /** Product Classification data */ + /** True when editing a saved product; see ngOnInit. */ + isExistingProduct = false; + + /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */ readonly productTypeGroups = [ - { id: 'loans', label: 'Loan' }, - { id: 'advanced', label: 'Advanced' } + { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, + { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } ]; - readonly productTypeSubGroups: Record = { - loans: [ - { id: 'emi', label: 'EMI' }, - { id: 'cc', label: 'CC (Cash Credit)' }, - { id: 'overdraft', label: 'Overdraft' }, - { id: 'warehouse', label: 'Warehouse' }, - { id: 'gold', label: 'Gold' } + /** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ + readonly productTypeSubGroups: Record = { + advances: [ + { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, + { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, + { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, + { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, + { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, + { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, + { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, + { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, + { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, + { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } ], - advanced: [ - { id: 'fd', label: 'FD (Fixed Deposit)' }, - { id: 'rd', label: 'RD (Recurring Deposit)' } + loans: [ + { id: 'term', label: 'Term Loan', shortName: 'TL' }, + { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, + { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, + { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, + { id: 'education', label: 'Education Loan', shortName: 'EL' }, + { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, + { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } ] }; - /** Default GL per product type group */ - private readonly glDefaultMap: Record = { - loans: 'asset', - advanced: 'asset' - }; + /** Regulatory Classification options (UI-only; RBI / bureau reporting). */ + readonly loanProductCategoryOptions = [ + { id: 'advances_deposits', label: 'Advances against deposits' }, + { id: 'gold_loan', label: 'Gold loan' }, + { id: 'agriculture', label: 'Agriculture' }, + { id: 'msme', label: 'MSME' }, + { id: 'personal', label: 'Personal / retail' }, + { id: 'housing', label: 'Housing' }, + { id: 'other', label: 'Other' } + ]; + + readonly prioritySectorOptions = [ + { id: 'no', label: 'No' }, + { id: 'agriculture', label: 'Agriculture' }, + { id: 'msme', label: 'MSME' }, + { id: 'weaker_sections', label: 'Weaker Sections' }, + { id: 'education', label: 'Education' }, + { id: 'housing', label: 'Housing' } + ]; + + readonly cibilAccountTypeOptions = [ + { id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' }, + { id: 'gold_loan', label: 'Gold Loan' }, + { id: 'overdraft', label: 'Overdraft' }, + { id: 'business_loan', label: 'Business Loan' }, + { id: 'other', label: 'Other' } + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + /** The rest of the Details form stays hidden on a new product until its type and sub type are picked. */ + get isProductTypeChosen(): boolean { + if (this.isExistingProduct) { + return true; + } + const value = this.loanProductDetailsForm.value; + return !!value.productTypeGroup && !!value.productTypeSubGroup; + } get availableSubGroups() { const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; return group ? (this.productTypeSubGroups[group] ?? []) : []; } - get glOptions(): string[] { - return [ - 'asset', - 'liability' - ]; - } - + /** + * Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are + * generated from. Separate from the Accounting tab mappings, which drive the journal entries. + */ assetAccountOptions: any[] = []; - liabilityAccountOptions: any[] = []; - - get availableGlAccounts(): any[] { - const type = this.loanProductDetailsForm.get('glClassification')?.value; - if (type === 'asset') return this.assetAccountOptions; - if (type === 'liability') return this.liabilityAccountOptions; - return []; - } - - get glClassificationLabel(): string { - const type = this.loanProductDetailsForm.get('glClassification')?.value; - return type === 'liability' ? 'Liability' : 'Asset'; - } get selectedGroupLabel(): string { const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; return this.productTypeGroups.find((g) => g.id === group)?.label ?? ''; } - readonly yesNoOptions = [ - { value: true, label: 'YES' }, - { value: false, label: 'NO' } - ]; + /** + * A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything + * that is not a letter is dropped, and only the first two letters are kept. Trimming here rather than with + * maxlength means a paste like 'g1old' still yields 'GO' instead of being cut to 'g1' first. + */ + normalizeShortName(event: Event) { + if (this.isExistingProduct) { + return; + } + // Read from the input itself: this handler can run before the form control has taken the new value. + const typed = (event.target as HTMLInputElement).value ?? ''; + const cleaned = typed + .toUpperCase() + .replace(/[^A-Z]/g, '') + .slice(0, 2); + if (cleaned !== typed) { + this.loanProductDetailsForm.get('shortName').setValue(cleaned); + } + } createLoanProductDetailsForm() { this.loanProductDetailsForm = this.formBuilder.group({ @@ -150,21 +211,26 @@ export class LoanProductDetailsStepComponent implements OnInit { closeDate: [''], productTypeGroup: [''], productTypeSubGroup: [''], - glClassification: ['asset'], glAccount: [''], - debitIntCalculation: [false], - separatePenalInterest: [false], - creditIntCalculation: [false], - subAccountTypeBalance: [false], - finalIntRounding: [false], - roundingUpTo: [null] + // Regulatory Classification (UI-only; stripped in buildPayload) + loanProductCategory: ['advances_deposits'], + prioritySector: ['no'], + cibilAccountType: ['loan_bank_deposits'], + kfsEnabled: ['YES'], + aprEnabled: ['YES'] }); - // Auto-set GL based on product type group - this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => { - const defaultVal = this.glDefaultMap[group] ?? 'asset'; - this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal); - this.loanProductDetailsForm.get('glAccount')?.setValue(''); + // A sub type belongs to one group, so changing the group clears it. + this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => { + this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue(''); + }); + + // Picking a sub type pre-fills the Short Name with its code; the user can still change it. + this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => { + const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId); + if (subGroup) { + this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName); + } }); } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html new file mode 100644 index 000000000..30c8eda8e --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html @@ -0,0 +1,106 @@ + + +
+
+
+ +

Penal Charges

+
+
+ + Penal Charges Applicable + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Penal Rate (% p.a.) + + + + Applied On + + @for (opt of appliedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Penal Grace Days + + +
+
+ RBI: penal charges are kept separate from interest, never capitalised, and shown in the Key Fact Statement. +
+
+
+
+ +
+
+ +

Asset Classification (SMA / NPA)

+
+
+ + Delinquency Bucket (SMA) + + — None — + @for (bucket of delinquencyBucketOptions; track bucket.id) { + {{ bucket.name }} + } + + SMA 0–30 / 31–60 / 61–90 + + + NPA After (days overdue) + + + + NPA Trigger + + @for (opt of npaTriggerOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest after NPA + + @for (opt of interestAfterNpaOptions; track opt.id) { + {{ opt.label }} + } + + + + Grace on Arrears Ageing (days) + + Days before an amount counts as overdue + + + In-Arrears Tolerance (₹) + + Small balance ignored for arrears + +
+
+
+ + Upgrade from NPA only when all arrears are cleared + + + Keep standard while the pledged deposit fully covers the dues + +
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts new file mode 100644 index 000000000..0df4451de --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts @@ -0,0 +1,90 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6). + * UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-penal-npa-step', + templateUrl: './loan-product-penal-npa-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductPenalNpaStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + @Input() loanProductsTemplate: any; + + loanProductPenalNpaForm: UntypedFormGroup; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly appliedOnOptions = [ + { id: 'overdue', label: 'Overdue amount' }, + { id: 'outstanding', label: 'Full outstanding' } + ]; + + readonly npaTriggerOptions = [ + { id: 'maturity', label: 'Dues overdue after maturity' }, + { id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' } + ]; + + readonly interestAfterNpaOptions = [ + { id: 'undebited', label: 'Move to Undebited Interest GL' }, + { id: 'suspense', label: 'Move to Interest Suspense GL' } + ]; + + get delinquencyBucketOptions(): any[] { + return this.loanProductsTemplate?.delinquencyBucketOptions ?? []; + } + + constructor() { + this.loanProductPenalNpaForm = this.formBuilder.group({ + penalApplicable: ['YES'], + penalRate: [ + 2.0, + [ + Validators.min(0), + Validators.max(100) + ] + ], + penalAppliedOn: ['overdue'], + penalGraceDays: [0], + delinquencyBucketId: [''], + npaDays: [ + 90, + [ + Validators.required, + Validators.min(0) + ] + ], + npaTrigger: ['maturity'], + interestAfterNpa: ['undebited'], + graceOnArrearsAgeing: [0], + inArrearsTolerance: [0], + upgradeOnlyAllArrears: [true], + keepStandardIfDepositCovers: [true] + }); + } + + get loanProductPenalNpa() { + return this.loanProductPenalNpaForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html new file mode 100644 index 000000000..3a062eb6a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html @@ -0,0 +1,69 @@ + + +
+
+
+ +

Limit Renewal

+
+
+ + Renewal Allowed + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Renewal Period (months) + + + + Renewal Grace Days + + Days allowed after the renewal date + +
+
+ +
+
+ +

Pre-closure

+
+
+ + Pre-closure Allowed + + @for (opt of preclosureOptions; track opt.id) { + {{ opt.label }} + } + + + + Lock-in Period (months) + + + + Interest on Pre-closure + + @for (opt of preclosureInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+ + Renewal and pre-closure fees are set once, on the Charges step. +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts new file mode 100644 index 000000000..984ade3bc --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts @@ -0,0 +1,64 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Limit renewal & pre-closure rules for an Advances A/C product (Figma step 7). Fees live on the + * Charges step. UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-renewal-closure-step', + templateUrl: './loan-product-renewal-closure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class LoanProductRenewalClosureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductRenewalClosureForm: UntypedFormGroup; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly preclosureOptions = [ + { id: 'anytime', label: 'Anytime' }, + { id: 'after_lockin', label: 'After lock-in period' }, + { id: 'not_allowed', label: 'Not allowed' } + ]; + + readonly preclosureInterestOptions = [ + { id: 'till_closing', label: 'Till closing date (broken period)' }, + { id: 'full_period', label: 'Full current period' } + ]; + + constructor() { + this.loanProductRenewalClosureForm = this.formBuilder.group({ + renewalAllowed: ['YES'], + renewalPeriodMonths: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + renewalGraceDays: [30], + preclosureAllowed: ['anytime'], + lockInMonths: [0], + interestOnPreclosure: ['till_closing'] + }); + } + + get loanProductRenewalClosure() { + return this.loanProductRenewalClosureForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html new file mode 100644 index 000000000..c2410aea5 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html @@ -0,0 +1,160 @@ + + +
+ @if (!isAdvance) { +
+ + Security & Lien applies to an Advances A/C product. Choose it on the Details step. +
+ } + + @if (isAdvance) { +
+
+ +

Security & Margin

+
+
+ + Security Type + + From Sub Type — not chosen again + + + + Eligible Amount Basis + + @for (opt of eligibleAmountBasisOptions; track opt.id) { + {{ opt.label }} + } + + Eligible Amount Basis is required + + + + Margin (%) + + 0 – 100. Drawing Power = security value × LTV + @if (loanProductSecurityLienForm.get('margin').hasError('required')) { + Margin is required + } @else { + Margin must be between 0 and 100 + } + + + + LTV (%) + + = 100 − Margin + +
+
+ + @if (isLienSubType) { +
+
+ +

Lien Settings

+ FD · RD · DDS · LIC +
+ +
+

Lien marking

+
+ + Lien Required + + + + + Lien Marking Stage + + @for (opt of lienMarkingStageOptions; track opt.id) { + {{ opt.label }} + } + + + + + Eligible Deposit Products + + @for (product of depositProductOptions; track product) { + {{ product }} + } + + Only accounts of these deposit products can be pledged for this advance + Pick at least one deposit product + +
+
+ +
+

Rules

+
+ + Tenure cannot exceed deposit maturity date + + + Auto-release lien when the advance is closed + + + Allow multiple deposits for one advance + + + Adjust advance from deposit proceeds at maturity + + + Allow third-party deposit (consent letter required) + + + Allow deposit renewal under lien (lien carries forward) + +
+
+ +
+

Lien certificate

+
+ + Lien Certificate Required + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + @if (loanProductSecurityLienForm.get('lienCertificateRequired').value === 'YES') { + + Certificate No. Prefix + + Prefix is required + + + + Print on Disbursement + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + } +
+
+
+ +
+ + Interest basis and spread are set on the Interest step; the lien-certificate fee on the + Charges step. +
+ } + } +
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss new file mode 100644 index 000000000..7d57dfc5e --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss @@ -0,0 +1,117 @@ +/** + * 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/. + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.lsl-section { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } + + &__hint { + margin-left: auto; + font-size: 12px; + color: #5f6368; + } +} + +.lsl-subsection { + padding: 16px 24px; + + & + & { + border-top: 1px solid $border; + } +} + +.lsl-group { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #5f6368; +} + +.lsl-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.lsl-subsection .lsl-grid { + padding: 0; +} + +.lsl-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.lsl-checks { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 32px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.lsl-note { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + padding: 12px 24px; + border: 1px dashed $border; + border-radius: 10px; + font-size: 13px; + color: #5f6368; + + i { + color: $accent; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts new file mode 100644 index 000000000..d0327c94a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts @@ -0,0 +1,196 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, Input, inject } from '@angular/core'; +import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Security & Lien step, shown only for an "Advances A/C" product. + * + * For a deposit-backed advance (Loan against FD / RD / DDS / LIC) the bank marks a lien on the + * customer's own deposit. This step captures the product-level rules for that: the margin (from which + * the LTV and drawing power follow), which deposit products may be pledged, and the lien certificate. + * + * UI-only for now: its values are NOT sent in the create payload, because the backend has no column to + * store them yet. Once the satellite table lands, wire `loanProductSecurityLien` into the payload. + */ +@Component({ + selector: 'mifosx-loan-product-security-lien-step', + templateUrl: './loan-product-security-lien-step.component.html', + styleUrls: ['./loan-product-security-lien-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTooltip, + MatCheckbox + ] +}) +export class LoanProductSecurityLienStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + + @Input() loanProductsTemplate: any; + /** The Details step's Product Type controls, so this step reacts to the chosen group / sub type. */ + @Input() productTypeGroup: AbstractControl; + @Input() productTypeSubGroup: AbstractControl; + + loanProductSecurityLienForm: UntypedFormGroup; + + /** Sub type -> the security it is backed by. Only deposit / policy sub types get a lien section. */ + private readonly securityTypeBySubGroup: Record = { + fd: 'Fixed Deposit', + rd: 'Recurring Deposit', + dds: 'Daily Deposit Scheme', + lic: 'LIC Policy', + gold: 'Gold Ornaments', + warehouse: 'Warehouse Receipt', + cc: 'Stock / Book Debts', + overdraft: 'Deposit / Property' + }; + + /** Sub types whose security is lien-marked on a deposit (rather than pledged / hypothecated). */ + private readonly lienSubGroups = [ + 'fd', + 'rd', + 'dds', + 'lic' + ]; + + /** Default margin % per sub type (bank policy in real use); LTV is derived from it. */ + private readonly defaultMarginBySubGroup: Record = { + fd: 10, + rd: 10, + dds: 10, + lic: 40 + }; + + readonly eligibleAmountBasisOptions = [ + { id: 'balance_accrued', label: 'Deposit balance + accrued interest' }, + { id: 'balance', label: 'Deposit balance only' }, + { id: 'surrender', label: 'Surrender value (LIC)' } + ]; + + readonly lienMarkingStageOptions = [ + { id: 'disbursement', label: 'At Disbursement' }, + { id: 'sanction', label: 'At Sanction' } + ]; + + readonly depositProductOptions = [ + 'Fixed Deposit – Regular', + 'Fixed Deposit – Senior Citizen', + 'Recurring Deposit – Regular', + 'Daily Deposit Scheme' + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.createForm(); + } + + ngOnInit() { + this.applySubGroup(this.productTypeSubGroup?.value); + // Keep Security Type and the default margin in step with the sub type chosen on the Details step. + this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup)); + } + + private applySubGroup(subGroup: string): void { + this.loanProductSecurityLienForm + .get('securityType') + .setValue(this.securityTypeBySubGroup[subGroup] ?? '', { emitEvent: false }); + const margin = this.defaultMarginBySubGroup[subGroup]; + if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) { + this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false }); + } + } + + /** This whole step is only relevant to an Advances A/C product. */ + get isAdvance(): boolean { + return this.productTypeGroup?.value === 'advances'; + } + + /** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */ + get isLienSubType(): boolean { + return this.lienSubGroups.includes(this.productTypeSubGroup?.value); + } + + /** LTV = 100 − margin, shown read-only next to the margin. */ + get ltv(): number | null { + const margin = this.loanProductSecurityLienForm.get('margin').value; + if (margin === null || margin === '' || isNaN(margin)) { + return null; + } + return 100 - Number(margin); + } + + createForm() { + this.loanProductSecurityLienForm = this.formBuilder.group({ + securityType: [{ value: '', disabled: true }], + eligibleAmountBasis: [ + 'balance_accrued', + Validators.required + ], + margin: [ + 10, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + lienMarkingStage: [ + 'disbursement', + Validators.required + ], + eligibleDepositProducts: [ + ['Fixed Deposit – Regular'], + Validators.required + ], + tenureNotExceedMaturity: [true], + autoReleaseOnClosure: [true], + allowMultipleDeposits: [false], + adjustFromMaturity: [true], + allowThirdPartyDeposit: [false], + allowRenewalUnderLien: [true], + lienCertificateRequired: ['YES'], + lienCertificatePrefix: [ + 'LC/FD/', + Validators.required + ], + printOnDisbursement: ['YES'] + }); + } + + /** Flat values for the payload. Not wired into submit yet — see the class comment. */ + get loanProductSecurityLien() { + const value = this.loanProductSecurityLienForm.getRawValue(); + return { + securityType: value.securityType, + eligibleAmountBasis: value.eligibleAmountBasis, + margin: value.margin, + ltv: this.ltv, + lienMarkingStage: value.lienMarkingStage, + eligibleDepositProducts: value.eligibleDepositProducts, + lienRules: { + tenureNotExceedMaturity: value.tenureNotExceedMaturity, + autoReleaseOnClosure: value.autoReleaseOnClosure, + allowMultipleDeposits: value.allowMultipleDeposits, + adjustFromMaturity: value.adjustFromMaturity, + allowThirdPartyDeposit: value.allowThirdPartyDeposit, + allowRenewalUnderLien: value.allowRenewalUnderLien + }, + lienCertificateRequired: value.lienCertificateRequired, + lienCertificatePrefix: value.lienCertificatePrefix, + printOnDisbursement: value.printOnDisbursement + }; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html index 5853a8895..c255df105 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html @@ -7,1100 +7,1212 @@ -->
- -
-
- -

- {{ 'labels.inputs.products.loan.Amortization' | translate }} & - {{ 'labels.inputs.Interest Method' | translate }} -

-
-
- - {{ 'labels.inputs.products.loan.Amortization' | translate }} - - @for (amortizationType of amortizationTypeData; track amortizationType) { - - {{ 'labels.inputs.products.loan.amortizationType.' + amortizationType.value | translate }} - - } - - - {{ 'labels.inputs.products.loan.Amortization' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Interest Method' | translate }} - - @for (interestType of interestTypeData; track interestType) { - - {{ interestType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest Method' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Interest calculation period' | translate }} - - @for ( - interestCalculationPeriodType of interestCalculationPeriodTypeData; - track interestCalculationPeriodType - ) { - - {{ interestCalculationPeriodType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest calculation period' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - -
- - {{ 'labels.inputs.products.loan.Is Equal Amortization?' | translate }} - -
- - @if (loanProductSettingsForm.value.interestCalculationPeriodType === 1) { -
- - {{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }} - -
- } -
-
- - -
-
- -

{{ 'labels.inputs.Loan Schedule' | translate }}

-
-
- - {{ 'labels.inputs.Loan Schedule Type' | translate }} - - @for (loanScheduleType of loanScheduleTypeData; track loanScheduleType) { - - {{ loanScheduleType.value | translateKey: 'catalogs' }} - - } - - - - - {{ 'labels.inputs.Repayment strategy' | translate }} - - @for ( - transactionProcessingStrategy of transactionProcessingStrategyData; - track transactionProcessingStrategy - ) { - - {{ transactionProcessingStrategy.name | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Repayment strategy' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - @if (isAdvancedTransactionProcessingStrategy) { + + + + + + + {{ 'labels.inputs.products.loan.Amortization' | translate }} & + {{ 'labels.inputs.Interest Method' | translate }} + + +
- {{ 'labels.inputs.Loan Schedule Processing Type' | translate }} - - @for (loanScheduleProcessingType of loanScheduleProcessingTypeData; track loanScheduleProcessingType) { - - {{ loanScheduleProcessingType.value | translateKey: 'catalogs' }} - - } - - - } - - - {{ 'labels.inputs.Interest free period' | translate }} - - - - - Allowed pending EMI amount - - - - - {{ 'labels.inputs.Days in year' | translate }} - - @for (daysInYearType of daysInYearTypeData; track daysInYearType) { - - {{ daysInYearType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Days in year' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - @if (useDaysInYearCustomStrategy) { - - {{ 'labels.inputs.Days in year custom strategy' | translate }} + {{ 'labels.inputs.products.loan.Amortization' | translate }} - @for (daysInYearCustomStrategy of daysInYearCustomStrategyOptions; track daysInYearCustomStrategy) { - - {{ daysInYearCustomStrategy.value | translateKey: 'catalogs' }} + @for (amortizationType of amortizationTypeData; track amortizationType) { + + {{ 'labels.inputs.products.loan.amortizationType.' + amortizationType.value | translate }} } + + {{ 'labels.inputs.products.loan.Amortization' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + - } - - {{ 'labels.inputs.Days in month' | translate }} - - @for (daysInMonthType of daysInMonthTypeData; track daysInMonthType) { - - {{ daysInMonthType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Days in month' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - -
- - {{ 'labels.inputs.Allow fixing of the installment amount' | translate }} - -
-
-
- - -
-
- -

Cheque Book Settings

-
-
- - Cheque Book Issue Enabled - - YES - NO - - - - - Cheque Validation Required - - YES - NO - - - - - Is First Cheque Book Free - - YES - NO - - - - - Cheque Printing Account Type - - Savings Bank - Current Account - Cash Credit - Overdraft - - - - - Allow Withdrawal Slip Usage - - YES - NO - - - - - Maintain Cheque Book Stock Registry - - YES - NO - - - - - Free Cheque Books Per Year - - - - - Allow Credit By Cheque Number - - YES - NO - - - - - Credit Cheque Validation Required - - YES - NO - - - - - Grace Days For Stock Statement - - -
-
- - -
-
- -

Additional Product Settings

-
-
- - Account Type Active For Transactions - - YES - NO - - - - - Sub Type Activity Enabled - - YES - NO - - - - - Account Opening Charges - - - - - Maximum Gold Loan Limit - - - - - Last Account Number Opened - - - - - Customer Termination Allowed - - YES - NO - - - - - Charges For Cash Credit Above Limit - - - - - Death Interest GL Account - - - - - Starting Ledger Number - - - - - Ending Ledger Number - - - - - Current Ledger Number - - - - - Remittance Account Number - - - - - Is Non Resident Indian Account - - YES - NO - - - - - Debit Clearing GL Account - - - - - Missed Call Alert Service Active - - YES - NO - - - - - Standing Instruction Charge GL Account - - - - - Standing Instruction Debit Charge Skip Option - - Skip Always - Skip on First Failure - Never Skip - Retry after 24 Hours - - - - - Opening Balance Multiplier For Remittance Charges - - -
-
- - -
-
- -

Interest Related Parameters

-
-
- - Drawing Power And Limit Generation Procedure - - Normal D.P. Entry Is Considered - Against F.D. Interest with Certificate Wise Interest Rate + {{ 'labels.inputs.Interest Method' | translate }} + - Based on Asset Value - Monthly Stock Statement Basis - - + @for (interestType of interestTypeData; track interestType) { + + {{ interestType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest Method' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + - - Advances Interest Calculation Method - - Debit Closing Balance Considered as Product + {{ 'labels.inputs.Interest calculation period' | translate }} + - - + @for ( + interestCalculationPeriodType of interestCalculationPeriodTypeData; + track interestCalculationPeriodType + ) { + + {{ interestCalculationPeriodType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest calculation period' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + - - Against Interest Calculation Enabled - - YES - NO - - - - - Day Wise Against Clearing Interest Rounding - - YES - NO - - - - - Interest Calculation One Fourth Method - - 0 (Standard) - 1 (Custom A) - 2 (Custom B) - - - - - Against Clearing Interest Procedure Type - - Normal Against Clearing Interest - - - - - Consider Debit Closing Amount For Against Clearing - - YES - NO - - - - - Interest Rounding Method - - All Int. Round - Nearest Rupee - Round Up - Round Down - - - - - Penalty Interest Calculation Enabled - - YES - NO - - - - - Penalty Interest Calculation Method - - Daywise ( 36500 ) - - - - - Penalty Interest Procedure Number - - On Debit, Penalty Product is Closing Balance - Limit is Considered for Penalty Product - - - - - Overdrawn Interest Calculation Method - - Overdrawn = Balance - Limit - Overdrawn = Balance - D.P. - Don't Calculate - - - - - Calculate Overdrawn Interest After Renewal - - YES - NO - - - - - Interest On Credit Balance - - YES - NO - - - - - Penalty Interest Date Different Than Regular Interest - - YES - NO - - - - - Fixed Deposit Overdraft Certificate Sort Order - - Order on Interest Rate - Order on Drawing Power - - - - - Use Document Submission Date For Interest - - YES - NO - - - - - Loan Management System Renewal Penalty Active - - YES - NO - - - - - Minimum Normal Interest Amount - - - - - Minimum Interest Calculation Method - - Flat Amount - Percentage of Balance - Tier Based - - - - - Minimum Against Clearing Interest Amount - - - - - Limit Increase Upto Interest Amount In Interest Calculation - - YES - NO - - - - - Minimum Penalty Interest Amount - - - - - Days For Limit Increase Upto Interest Amount - - - - - Additional Interest Rate For Fixed Deposit Overdraft - - - - - Consider DD/Payorder In Against Clearing Interest Calculation - - YES - NO - - - - - Grace Days After Renewal Date - - - - - Apply Undebited Interest Concept Enabled - - YES - NO - - - - - Interest Register Print Format Type - - Interest Amount with Opening Balance - Interest Only - Full Ledger Format - - - - - Normal Grace Days After Overdue - - - - - Allow Against Clearing Entry - - YES - NO - - - - - Allow No Balance Entry - - YES - NO - - - - - Fixed Deposit Overdraft Penalty Interest Combined With Normal Interest - - YES - NO - - - - - Allow No Balance Entry With Password - - YES - NO - - - - - Undebited Simple Interest Account Wise - - YES - NO - - - - - Daily Interest Calculation Enabled - - YES - NO - - - - - Daily Interest Calculation Start Date - - - - - Non Performing Asset Penal Interest Rate Update - - YES - NO - - - - - Non Performing Asset Penal Interest Rate - - - - - Fixed Deposit Overdraft Penal Interest Rate Update In Branch Process - - YES - NO - - - - - Add Penal Interest Rate - - -
-
- - -
-
- -

RBI Compliance & KFS Settings

-
-
- - Penal Charge Method - - RBI Compliant Penal Charges (Non-Compounding) - Legacy Penal Interest (Compounding) - - - -
- - Is Floating Rate Loan? (Individual) - -
- - - Foreclosure Charges Applicable - - YES - NO - - RBI: No charges for Floating Rate Loans to Individuals - - -
- - Enable Key Fact Statement (KFS) - -
- -
- - Calculate APR - -
-
-
- - -
-
- -

Credit Bureau Settings (CIBIL/Highmark)

-
-
- - Loan Product Category (RBI Reporting) - - Personal/Consumer Loan (Code 02) - Housing/Mortgage Loan (Code 15) - Business/Commercial Loan (Code 50) - Vehicle/Auto Loan (Code 04) - - Selecting a category will auto-fill the credit bureau account types below. - - - CIBIL Account Type - - - - - CIBIL Commercial Account Type - - - - - Highmark Account Type - - - - - Highmark Commercial Account Type - - - - - Experian Account Type - - - - - Experian Commercial Account Type - - - - - Equifax Account Type - - - - - Equifax Commercial Account Type - - -
-
- - -
-
- -

- {{ 'labels.inputs.Moratorium' | translate }} - -

-
-
- - {{ 'labels.inputs.Grace on principal payment' | translate }} - - - - - {{ 'labels.inputs.Grace on interest payment' | translate }} - - - - - {{ 'labels.inputs.Delinquency Bucket' | translate }} - - @for (delinquencyBucket of delinquencyBucketData; track delinquencyBucket) { - - {{ delinquencyBucket.name }} - - } - - @if (loanProductSettingsForm.controls.delinquencyBucketId) { - - } - - - @if (loanProductSettingsForm.value.delinquencyBucketId) { -
- - {{ 'labels.inputs.Enable installment level Delinquency' | translate }} +
+ + {{ 'labels.inputs.products.loan.Is Equal Amortization?' | translate }}
- } -
-
- -
-
- -

- {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} -

-
-
- - {{ 'labels.inputs.On arrears ageing' | translate }} - - - - - {{ 'labels.inputs.Overdue days for NPA' | translate }} - - - -
- - {{ 'labels.inputs.Account moves out of NPA only after all arrears have been cleared' | translate }} - + @if (loanProductSettingsForm.value.interestCalculationPeriodType === 1) { +
+ + {{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }} + +
+ }
+ - - {{ 'labels.inputs.Principal Threshold (%) for Last Instalment' | translate }} - - -
-
- - -
-
- -

{{ 'labels.inputs.Variable Installments' | translate }}

-
-
-
- - {{ 'labels.inputs.Are Variable Installments allowed?' | translate }} - -
- - @if (loanProductSettingsForm.value.allowVariableInstallments) { + + + + + + {{ 'labels.inputs.Loan Schedule' | translate }} + + +
- {{ 'labels.inputs.Minimum gap between Installments' | translate }} - - - {{ 'labels.inputs.Minimum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - + {{ 'labels.inputs.Loan Schedule Type' | translate }} + + @for (loanScheduleType of loanScheduleTypeData; track loanScheduleType) { + + {{ loanScheduleType.value | translateKey: 'catalogs' }} + + } + + - {{ 'labels.inputs.Maximum gap between Installments' | translate }} - - - {{ 'labels.inputs.Maximum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - } - -
- - {{ 'labels.inputs.Allowed to be used for providing Topup Loans' | translate }} - -
-
-
- - -
-
- -

{{ 'labels.heading.Interest Recalculation' | translate }}

-
-
-
- - {{ 'labels.inputs.Recalculate Interest' | translate }} - -
- - @if (loanProductSettingsForm.value.isInterestRecalculationEnabled) { - - {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} - + {{ 'labels.inputs.Repayment strategy' | translate }} + @for ( - preClosureInterestCalculationStrategy of preClosureInterestCalculationStrategyData; - track preClosureInterestCalculationStrategy + transactionProcessingStrategy of transactionProcessingStrategyData; + track transactionProcessingStrategy ) { - - {{ preClosureInterestCalculationStrategy.value | translateKey: 'catalogs' }} + + {{ transactionProcessingStrategy.name | translateKey: 'catalogs' }} } - {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} - {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Repayment strategy' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} - - {{ 'labels.inputs.Advance payments adjustment type' | translate }} - - @for (rescheduleStrategyType of rescheduleStrategyTypeData; track rescheduleStrategyType) { - - {{ rescheduleStrategyType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Advance payments adjustment type' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Interest recalculation compounding on' | translate }} - - @for ( - interestRecalculationCompoundingType of interestRecalculationCompoundingTypeData; - track interestRecalculationCompoundingType - ) { - - {{ interestRecalculationCompoundingType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest recalculation compounding on' | translate }} - {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - @if ( - loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0 && - loanProductSettingsForm.value.recalculationCompoundingFrequencyType !== 1 - ) { + @if (isAdvancedTransactionProcessingStrategy) { - {{ 'labels.inputs.Frequency Interval for compounding' | translate }} - + {{ 'labels.inputs.Loan Schedule Processing Type' | translate }} + + @for (loanScheduleProcessingType of loanScheduleProcessingTypeData; track loanScheduleProcessingType) { + + {{ loanScheduleProcessingType.value | translateKey: 'catalogs' }} + + } + + + } + + + {{ 'labels.inputs.Interest free period' | translate }} + + + + + Allowed pending EMI amount + + + + @if (!isAdvanceAccount) { + + {{ 'labels.inputs.Days in year' | translate }} + + @for (daysInYearType of daysInYearTypeData; track daysInYearType) { + + {{ daysInYearType.value | translateKey: 'catalogs' }} + + } + - {{ 'labels.inputs.Frequency Interval for compounding' | translate }} - {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Days in year' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + @if (useDaysInYearCustomStrategy) { + + {{ 'labels.inputs.Days in year custom strategy' | translate }} + + @for (daysInYearCustomStrategy of daysInYearCustomStrategyOptions; track daysInYearCustomStrategy) { + + {{ daysInYearCustomStrategy.value | translateKey: 'catalogs' }} + + } + + + } + + + {{ 'labels.inputs.Days in month' | translate }} + + @for (daysInMonthType of daysInMonthTypeData; track daysInMonthType) { + + {{ daysInMonthType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Days in month' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - @if (loanProductSettingsForm.value.recalculationRestFrequencyType !== 1) { + + @if (isAdvanceAccount) { - {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} - + {{ 'labels.inputs.Loan Period' | translate }} + + @for (loanPeriod of loanPeriodOptions; track loanPeriod.value) { + + {{ loanPeriod.label }} + + } + - {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} - {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} + {{ 'labels.inputs.Loan Period' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} } - - @if (loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0) { + @if (!isAdvanceAccount) { +
+ + {{ 'labels.inputs.Allow fixing of the installment amount' | translate }} + +
+ } +
+ + + + + + + + Cheque Book Settings + + +
+ + Cheque Book Issue Enabled + + YES + NO + + + + + Cheque Validation Required + + YES + NO + + + + + Is First Cheque Book Free + + YES + NO + + + + + Cheque Printing Account Type + + Savings Bank + Current Account + Cash Credit + Overdraft + + + + + Allow Withdrawal Slip Usage + + YES + NO + + + + + Maintain Cheque Book Stock Registry + + YES + NO + + + + + Free Cheque Books Per Year + + + + + Allow Credit By Cheque Number + + YES + NO + + + + + Credit Cheque Validation Required + + YES + NO + + + + + Grace Days For Stock Statement + + +
+
+ + + + + + + Additional Product Settings + + +
+ + Account Type Active For Transactions + + YES + NO + + + + + Sub Type Activity Enabled + + YES + NO + + + + + Account Opening Charges + + + + + Maximum Gold Loan Limit + + + + + Last Account Number Opened + + + + + Customer Termination Allowed + + YES + NO + + + + + Charges For Cash Credit Above Limit + + + + + Death Interest GL Account + + + + + Starting Ledger Number + + + + + Ending Ledger Number + + + + + Current Ledger Number + + + + + Remittance Account Number + + + + + Is Non Resident Indian Account + + YES + NO + + + + + Debit Clearing GL Account + + + + + Missed Call Alert Service Active + + YES + NO + + + + + Standing Instruction Charge GL Account + + + + + Standing Instruction Debit Charge Skip Option + + Skip Always + Skip on First Failure + Never Skip + Retry after 24 Hours + + + + + Opening Balance Multiplier For Remittance Charges + + +
+
+ + + + + + + Interest Related Parameters + + +
+ + Drawing Power And Limit Generation Procedure + + Normal D.P. Entry Is Considered + Against F.D. Interest with Certificate Wise Interest Rate + Based on Asset Value + Monthly Stock Statement Basis + + + + + Advances Interest Calculation Method + + Debit Closing Balance Considered as Product + + + + + Against Interest Calculation Enabled + + YES + NO + + + + + Day Wise Against Clearing Interest Rounding + + YES + NO + + + + + Interest Calculation One Fourth Method + + 0 (Standard) + 1 (Custom A) + 2 (Custom B) + + + + + Against Clearing Interest Procedure Type + + Normal Against Clearing Interest + + + + + Consider Debit Closing Amount For Against Clearing + + YES + NO + + + + + Interest Rounding Method + + All Int. Round + Nearest Rupee + Round Up + Round Down + + + + + Penalty Interest Calculation Enabled + + YES + NO + + + + + Penalty Interest Calculation Method + + Daywise ( 36500 ) + + + + + Penalty Interest Procedure Number + + On Debit, Penalty Product is Closing Balance + Limit is Considered for Penalty Product + + + + + Overdrawn Interest Calculation Method + + Overdrawn = Balance - Limit + Overdrawn = Balance - D.P. + Don't Calculate + + + + + Calculate Overdrawn Interest After Renewal + + YES + NO + + + + + Interest On Credit Balance + + YES + NO + + + + + Penalty Interest Date Different Than Regular Interest + + YES + NO + + + + + Fixed Deposit Overdraft Certificate Sort Order + + Order on Interest Rate + Order on Drawing Power + + + + + Use Document Submission Date For Interest + + YES + NO + + + + + Loan Management System Renewal Penalty Active + + YES + NO + + + + + Minimum Normal Interest Amount + + + + + Minimum Interest Calculation Method + + Flat Amount + Percentage of Balance + Tier Based + + + + + Minimum Against Clearing Interest Amount + + + + + Limit Increase Upto Interest Amount In Interest Calculation + + YES + NO + + + + + Minimum Penalty Interest Amount + + + + + Days For Limit Increase Upto Interest Amount + + + + + Additional Interest Rate For Fixed Deposit Overdraft + + + + + Consider DD/Payorder In Against Clearing Interest Calculation + + YES + NO + + + + + Grace Days After Renewal Date + + + + + Apply Undebited Interest Concept Enabled + + YES + NO + + + + + Interest Register Print Format Type + + Interest Amount with Opening Balance + Interest Only + Full Ledger Format + + + + + Normal Grace Days After Overdue + + + + + Allow Against Clearing Entry + + YES + NO + + + + + Allow No Balance Entry + + YES + NO + + + + + Fixed Deposit Overdraft Penalty Interest Combined With Normal Interest + + YES + NO + + + + + Allow No Balance Entry With Password + + YES + NO + + + + + Undebited Simple Interest Account Wise + + YES + NO + + + + + Daily Interest Calculation Enabled + + YES + NO + + + + + Daily Interest Calculation Start Date + + + + + Non Performing Asset Penal Interest Rate Update + + YES + NO + + + + + Non Performing Asset Penal Interest Rate + + + + + Fixed Deposit Overdraft Penal Interest Rate Update In Branch Process + + YES + NO + + + + + Add Penal Interest Rate + + +
+
+ + + + + + + RBI Compliance & KFS Settings + + +
+ + Penal Charge Method + + RBI Compliant Penal Charges (Non-Compounding) + Legacy Penal Interest (Compounding) + + + +
+ + Is Floating Rate Loan? (Individual) + +
+ + + Foreclosure Charges Applicable + + YES + NO + + RBI: No charges for Floating Rate Loans to Individuals + + +
+ + Enable Key Fact Statement (KFS) + +
+ +
+ + Calculate APR + +
+
+
+ + + + + + + Credit Bureau Settings (CIBIL/Highmark) + + +
+ + Loan Product Category (RBI Reporting) + + Personal/Consumer Loan (Code 02) + Housing/Mortgage Loan (Code 15) + Business/Commercial Loan (Code 50) + Vehicle/Auto Loan (Code 04) + + Selecting a category will auto-fill the credit bureau account types below. + + + CIBIL Account Type + + + + + CIBIL Commercial Account Type + + + + + Highmark Account Type + + + + + Highmark Commercial Account Type + + + + + Experian Account Type + + + + + Experian Commercial Account Type + + + + + Equifax Account Type + + + + + Equifax Commercial Account Type + + +
+
+ + + + + + + {{ 'labels.inputs.Moratorium' | translate }} + + + +
+ + {{ 'labels.inputs.Grace on principal payment' | translate }} + + + + + {{ 'labels.inputs.Grace on interest payment' | translate }} + + + + + {{ 'labels.inputs.Delinquency Bucket' | translate }} + + @for (delinquencyBucket of delinquencyBucketData; track delinquencyBucket) { + + {{ delinquencyBucket.name }} + + } + + @if (loanProductSettingsForm.controls.delinquencyBucketId) { + + } + + + @if (loanProductSettingsForm.value.delinquencyBucketId) { +
+ + {{ 'labels.inputs.Enable installment level Delinquency' | translate }} + +
+ } +
+
+ + + + + + + {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} + + +
+ + {{ 'labels.inputs.On arrears ageing' | translate }} + + + + + {{ 'labels.inputs.Overdue days for NPA' | translate }} + + + +
+ + {{ 'labels.inputs.Account moves out of NPA only after all arrears have been cleared' | translate }} + +
+ + + {{ 'labels.inputs.Principal Threshold (%) for Last Instalment' | translate }} + + +
+
+ + + + + + + {{ 'labels.inputs.Variable Installments' | translate }} + + +
+
+ + {{ 'labels.inputs.Are Variable Installments allowed?' | translate }} + +
+ + @if (loanProductSettingsForm.value.allowVariableInstallments) { - {{ 'labels.inputs.Frequency for compounding' | translate }} - + {{ 'labels.inputs.Minimum gap between Installments' | translate }} + + + {{ 'labels.inputs.Minimum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + {{ 'labels.inputs.Maximum gap between Installments' | translate }} + + + {{ 'labels.inputs.Maximum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + } + +
+ + {{ 'labels.inputs.Allowed to be used for providing Topup Loans' | translate }} + +
+
+
+ + + + + + + {{ 'labels.heading.Interest Recalculation' | translate }} + + +
+
+ + {{ 'labels.inputs.Recalculate Interest' | translate }} + +
+ + @if (loanProductSettingsForm.value.isInterestRecalculationEnabled) { + + {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} + + @for ( + preClosureInterestCalculationStrategy of preClosureInterestCalculationStrategyData; + track preClosureInterestCalculationStrategy + ) { + + {{ preClosureInterestCalculationStrategy.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} + {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Advance payments adjustment type' | translate }} + + @for (rescheduleStrategyType of rescheduleStrategyTypeData; track rescheduleStrategyType) { + + {{ rescheduleStrategyType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Advance payments adjustment type' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Interest recalculation compounding on' | translate }} + + @for ( + interestRecalculationCompoundingType of interestRecalculationCompoundingTypeData; + track interestRecalculationCompoundingType + ) { + + {{ interestRecalculationCompoundingType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest recalculation compounding on' | translate }} + {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + @if ( + loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0 && + loanProductSettingsForm.value.recalculationCompoundingFrequencyType !== 1 + ) { + + {{ 'labels.inputs.Frequency Interval for compounding' | translate }} + + + {{ 'labels.inputs.Frequency Interval for compounding' | translate }} + {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + } + @if (loanProductSettingsForm.value.recalculationRestFrequencyType !== 1) { + + {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} + + + {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} + {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} + + + } + + + @if (loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0) { + + {{ 'labels.inputs.Frequency for compounding' | translate }} + + @for ( + interestRecalculationFrequencyType of interestRecalculationFrequencyTypeData; + track interestRecalculationFrequencyType + ) { + + {{ interestRecalculationFrequencyType.value }} + + } + + + + @if (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4) { + + + @for ( + interestRecalculationNthDayType of interestRecalculationNthDayTypeData; + track interestRecalculationNthDayType + ) { + + {{ interestRecalculationNthDayType.value | translateKey: 'catalogs' }} + + } + + + } + + @if ( + (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && + loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType !== -2) || + loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 3 + ) { + + + @for ( + interestRecalculationDayOfWeekType of interestRecalculationDayOfWeekTypeData; + track interestRecalculationDayOfWeekType + ) { + + {{ interestRecalculationDayOfWeekType.value | translateKey: 'catalogs' }} + + } + + + } + + @if ( + loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && + loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType === -2 + ) { + + + @for ( + interestRecalculationOnDayType of interestRecalculationOnDayTypeData; + track interestRecalculationOnDayType + ) { + + {{ interestRecalculationOnDayType }} + + } + + + } + } + + + + {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} + @for ( interestRecalculationFrequencyType of interestRecalculationFrequencyTypeData; track interestRecalculationFrequencyType ) { - {{ interestRecalculationFrequencyType.value }} + {{ interestRecalculationFrequencyType.value | translateKey: 'catalogs' }} } + + {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} + {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} + - @if (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4) { + @if (loanProductSettingsForm.value.recalculationRestFrequencyType === 4) { - + @for ( interestRecalculationNthDayType of interestRecalculationNthDayTypeData; track interestRecalculationNthDayType @@ -1114,12 +1226,12 @@ } @if ( - (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && - loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType !== -2) || - loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 3 + (loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && + loanProductSettingsForm.value.recalculationRestFrequencyNthDayType !== -2) || + loanProductSettingsForm.value.recalculationRestFrequencyType === 3 ) { - + @for ( interestRecalculationDayOfWeekType of interestRecalculationDayOfWeekTypeData; track interestRecalculationDayOfWeekType @@ -1133,11 +1245,11 @@ } @if ( - loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && - loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType === -2 + loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && + loanProductSettingsForm.value.recalculationRestFrequencyNthDayType === -2 ) { - + @for ( interestRecalculationOnDayType of interestRecalculationOnDayTypeData; track interestRecalculationOnDayType @@ -1149,205 +1261,138 @@ } - } - - - {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} - - @for ( - interestRecalculationFrequencyType of interestRecalculationFrequencyTypeData; - track interestRecalculationFrequencyType - ) { - - {{ interestRecalculationFrequencyType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} - {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} - - - - @if (loanProductSettingsForm.value.recalculationRestFrequencyType === 4) { - - - @for ( - interestRecalculationNthDayType of interestRecalculationNthDayTypeData; - track interestRecalculationNthDayType - ) { - - {{ interestRecalculationNthDayType.value | translateKey: 'catalogs' }} - - } - - - } - - @if ( - (loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && - loanProductSettingsForm.value.recalculationRestFrequencyNthDayType !== -2) || - loanProductSettingsForm.value.recalculationRestFrequencyType === 3 - ) { - - - @for ( - interestRecalculationDayOfWeekType of interestRecalculationDayOfWeekTypeData; - track interestRecalculationDayOfWeekType - ) { - - {{ interestRecalculationDayOfWeekType.value | translateKey: 'catalogs' }} - - } - - - } - - @if ( - loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && - loanProductSettingsForm.value.recalculationRestFrequencyNthDayType === -2 - ) { - - - @for ( - interestRecalculationOnDayType of interestRecalculationOnDayTypeData; - track interestRecalculationOnDayType - ) { - - {{ interestRecalculationOnDayType }} - - } - - - } - -
- - {{ 'labels.inputs.Is Arrears recognization based on original schedule' | translate }}? - -
- - @if (loanProductSettingsForm.value.loanScheduleType === 'PROGRESSIVE') {
- - {{ 'labels.inputs.Do not calculate interest on past due principal balances' | translate }} + + {{ 'labels.inputs.Is Arrears recognization based on original schedule' | translate }}? + +
+ + @if (loanProductSettingsForm.value.loanScheduleType === 'PROGRESSIVE') { +
+ + {{ 'labels.inputs.Do not calculate interest on past due principal balances' | translate }} + +
+ } + +
+ + {{ 'labels.inputs.Allow accrual posting in arrears' | translate }} + +
+ +
+ + {{ 'labels.inputs.Allow compounding on EOD' | translate }} + +
+ +
+ + {{ 'labels.inputs.Post compounding as transaction' | translate }}
} - -
- - {{ 'labels.inputs.Allow accrual posting in arrears' | translate }} - -
- -
- - {{ 'labels.inputs.Allow compounding on EOD' | translate }} - -
- -
- - {{ 'labels.inputs.Post compounding as transaction' | translate }} - -
- } -
-
- - -
-
- -

{{ 'labels.heading.Guarantee Requirements' | translate }}

-
-
-
- - {{ 'labels.inputs.Place Guarantee Funds On-Hold' | translate }} -
+ - @if (loanProductSettingsForm.value.holdGuaranteeFunds) { - - {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} - - - {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - {{ 'labels.inputs.Minimum Guarantee from Own Funds(%)' | translate }} - - - - {{ 'labels.inputs.Minimum Guarantee from Guarantor Funds(%)' | translate }} - - - } -
-
- - -
-
- -

{{ 'labels.inputs.Configurable Terms and Settings' | translate }}

-
-
-
- - {{ 'labels.inputs.Allow overriding select terms and settings in loan accounts' | translate }} - -
- - @if (loanProductSettingsForm.value.allowAttributeConfiguration) { -
-
- - {{ 'labels.inputs.Amortization' | translate }} - -
-
- - {{ 'labels.inputs.Interest method' | translate }} - -
-
- - {{ 'labels.inputs.Repayment strategy' | translate }} - -
-
- - {{ 'labels.inputs.Interest calculation period' | translate }} - -
-
- - Allowed pending EMI amount - -
-
- - {{ 'labels.inputs.Repaid every' | translate }} - -
-
- - {{ 'labels.inputs.Moratorium' | translate }} - -
-
- - {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} - -
+ + + + + + {{ 'labels.heading.Guarantee Requirements' | translate }} + + +
+
+ + {{ 'labels.inputs.Place Guarantee Funds On-Hold' | translate }} +
- } -
-
+ + @if (loanProductSettingsForm.value.holdGuaranteeFunds) { + + {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} + + + {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + {{ 'labels.inputs.Minimum Guarantee from Own Funds(%)' | translate }} + + + + {{ 'labels.inputs.Minimum Guarantee from Guarantor Funds(%)' | translate }} + + + } +
+ + + + + + + + {{ 'labels.inputs.Configurable Terms and Settings' | translate }} + + +
+
+ + {{ 'labels.inputs.Allow overriding select terms and settings in loan accounts' | translate }} + +
+ + @if (loanProductSettingsForm.value.allowAttributeConfiguration) { +
+
+ + {{ 'labels.inputs.Amortization' | translate }} + +
+
+ + {{ 'labels.inputs.Interest method' | translate }} + +
+
+ + {{ 'labels.inputs.Repayment strategy' | translate }} + +
+
+ + {{ 'labels.inputs.Interest calculation period' | translate }} + +
+
+ + Allowed pending EMI amount + +
+
+ + {{ 'labels.inputs.Repaid every' | translate }} + +
+
+ + {{ 'labels.inputs.Moratorium' | translate }} + +
+
+ + {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} + +
+
+ } +
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss index 50934aecc..c14c4b170 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss @@ -71,6 +71,16 @@ } } +.lps-panel { + & + & { + margin-top: 12px; + } + + .lps-section__body { + padding-top: 8px; + } +} + .lps-full-width { grid-column: 1 / -1; } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts index d230afc79..5153727bf 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts @@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { + MatAccordion, + MatExpansionPanel, + MatExpansionPanelHeader, + MatExpansionPanelTitle +} from '@angular/material/expansion'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatCheckbox, MatIconButton, FaIconComponent, - ReactiveFormsModule + ReactiveFormsModule, + MatAccordion, + MatExpansionPanel, + MatExpansionPanelHeader, + MatExpansionPanelTitle ] }) export class LoanProductSettingsStepComponent implements OnInit { @@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit { @Input() toEdit: boolean; @Input() loanProductsTemplate: any; - @Input() isLinkedToFloatingInterestRates: UntypedFormControl; + @Input() productTypeGroup: UntypedFormControl; @Output() advancePaymentStrategy = new EventEmitter(); + /** + * Provided from the Terms step's control. It can arrive after this component's ngOnInit (the Terms tab + * resolves later now that the tabs are conditional), so subscribe from the setter when it is present + * rather than assuming it in ngOnInit — otherwise a null control blanks the whole wizard. + */ + private _isLinkedToFloatingInterestRates: UntypedFormControl; + private isLinkedSubscribed = false; + + @Input() + set isLinkedToFloatingInterestRates(control: UntypedFormControl) { + this._isLinkedToFloatingInterestRates = control; + if (control && !this.isLinkedSubscribed) { + this.isLinkedSubscribed = true; + control.valueChanges.subscribe((isLinked: any) => { + if (isLinked) { + this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true); + this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true); + } + }); + } + } + + get isLinkedToFloatingInterestRates(): UntypedFormControl { + return this._isLinkedToFloatingInterestRates; + } + loanProductSettingsForm: UntypedFormGroup; amortizationTypeData: any; @@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit { daysInYearCustomStrategyOptions: OptionData[] = []; useDaysInYearCustomStrategy = false; + /** True when the product being created is an "Advances A/C" (no-EMI, limit-based) product. */ + isAdvanceAccount = false; + + /** UI-only for now; not yet wired into the submitted payload (see loan-products.ts buildPayload). */ + readonly loanPeriodOptions = [ + { value: 12, label: '12 Months' }, + { value: 15, label: '15 Months' } + ]; + /** Values to Days for Repayments */ defaultConfigValues: GlobalConfiguration[] = []; @@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit { ngOnInit() { this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault']; - this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => { - if (isLinkedToFloatingInterestRates) { - this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true); - this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true); - } - }); + + if (this.productTypeGroup) { + this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances'); + this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => { + this.applyAdvanceAccountRules(productTypeGroup === 'advances'); + }); + } this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions; this.interestTypeData = this.loanProductsTemplate.interestTypeOptions; @@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit { Validators.required ], allowAccrualPostingInArrears: [false], + // Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload(). + loanPeriod: [''], // Cheque Book Settings chequeBookIssueEnabled: ['NO'], @@ -453,6 +501,28 @@ export class LoanProductSettingsStepComponent implements OnInit { } } + /** + * Advances A/C has no EMI, so the Loan Schedule section is simplified: schedule type is locked to + * Cumulative (Progressive only makes sense with Advanced Payment Allocation, which is an EMI concept), + * installment-amount fixing doesn't apply, and Days in year/month are replaced by a placeholder + * Loan Period pick. loanScheduleType/canDefineInstallmentAmount stay enabled (not `.disable()`d) so they + * remain in `loanProductSettingsForm.value` and the submitted payload is unaffected by this UI change. + */ + private applyAdvanceAccountRules(isAdvance: boolean): void { + this.isAdvanceAccount = isAdvance; + const loanPeriodControl = this.loanProductSettingsForm.get('loanPeriod'); + if (isAdvance) { + this.loanProductSettingsForm.patchValue({ + loanScheduleType: LoanProducts.LOAN_SCHEDULE_TYPE_CUMULATIVE, + canDefineInstallmentAmount: false + }); + loanPeriodControl.setValidators(Validators.required); + } else { + loanPeriodControl.clearValidators(); + } + loanPeriodControl.updateValueAndValidity(); + } + setConditionalControls() { const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides'); diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html index 6e765fbe3..7b68bec2d 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html @@ -264,6 +264,62 @@ }
+ +
+
+ +

Interest Information

+
+
+ + Debit Int. Calculation + + YES + NO + + + + + Separate Penal Int. + + YES + NO + + + + + Credit Int. Calculation + + YES + NO + + + + + Sub A/c Type Balance + + YES + NO + + + + + Final Int. Rounding + + YES + NO + + + + @if (loanProductTermsForm.value.finalIntRounding) { + + Rounding UpTo + + + } +
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts index 9e457b6d8..92f7e797b 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts @@ -111,7 +111,13 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges { repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1, debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false, allowDebitTransAfterDueDate: this.loanProductsTemplate.allowDebitTransAfterDueDate ?? false, - branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false + branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false, + debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false, + separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false, + creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false, + subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false, + finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false, + roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null }); if (this.loanProductsTemplate.allowApprovedDisbursedAmountsOverApplied) { @@ -213,7 +219,14 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges { fixedLength: [null], debitBalanceAllow: [false], allowDebitTransAfterDueDate: [false], - branchLevelAccountType: [false] + branchLevelAccountType: [false], + // Interest Information (moved here from the Details tab) + debitIntCalculation: [false], + separatePenalInterest: [false], + creditIntCalculation: [false], + subAccountTypeBalance: [false], + finalIntRounding: [false], + roundingUpTo: [null] }); } diff --git a/src/app/products/loan-products/loan-products.ts b/src/app/products/loan-products/loan-products.ts index 884b0a087..52e913db3 100644 --- a/src/app/products/loan-products/loan-products.ts +++ b/src/app/products/loan-products/loan-products.ts @@ -45,9 +45,29 @@ export class LoanProducts { dateFormat, locale }; - // Remove unnecessary properties + // Remove UI-only flags that are never valid loan-product parameters (both paths). delete loanProduct.allowAttributeConfiguration; delete loanProduct.advancedAccountingRules; + delete loanProduct.loanPeriod; + + // The Advances payload is assembled explicitly in the create component and already carries only + // whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped. + // For the Loans path, the Details/Security fields below are not backend params, so drop them. + if (loanProduct.productTypeGroup !== 'advances') { + delete loanProduct.productTypeGroup; + delete loanProduct.productTypeSubGroup; + delete loanProduct.glAccount; + delete loanProduct.loanProductCategory; + delete loanProduct.prioritySector; + delete loanProduct.cibilAccountType; + delete loanProduct.kfsEnabled; + delete loanProduct.aprEnabled; + delete loanProduct.securityType; + delete loanProduct.lienCertificateRequired; + delete loanProduct.securityMargin; + delete loanProduct.dpType; + delete loanProduct.dpValue; + } // In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field // allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload. From 885b39d970277f93016c9e277cbf20f982f45588 Mon Sep 17 00:00:00 2001 From: tarunmalviya Date: Thu, 1 Oct 2026 13:08:52 +0530 Subject: [PATCH 04/21] DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes --- src/app/accounts/accounts-routing.module.ts | 15 + src/app/core/shell/shell.component.html | 34 +- .../apply-dds-receipt-dialog.component.html | 381 ++++++++++++ .../apply-dds-receipt-dialog.component.scss | 95 +++ .../apply-dds-receipt-dialog.component.ts | 351 +++++++++++ .../daily-transaction-entry.component.html | 280 +++++++-- .../daily-transaction-entry.component.scss | 113 +++- .../daily-transaction-entry.component.ts | 355 +++++++++-- .../dds-account-view.component.html | 557 ++++++++++++++++++ .../dds-account-view.component.scss | 459 +++++++++++++++ .../dds-account-view.component.ts | 297 ++++++++++ .../dds-accounts/dds-accounts.component.html | 211 +++++++ .../dds-accounts/dds-accounts.component.scss | 56 ++ .../dds-accounts/dds-accounts.component.ts | 265 +++++++++ .../dds-approvals/dds-approval-notice.ts | 46 ++ .../dds-approval-review.component.html | 265 +++++++++ .../dds-approval-review.component.ts | 148 +++++ .../dds-approvals.component.html | 176 ++++++ .../dds-approvals/dds-approvals.component.ts | 215 +++++++ .../deposits/party-master/client-search.ts | 49 ++ .../create-party/create-party.component.html | 198 +++++-- .../create-party/create-party.component.ts | 278 +++++++-- .../due-for-closure.component.html | 7 +- .../due-for-closure.component.ts | 32 +- .../party-master/party-master.component.html | 24 +- .../party-master/party-master.component.ts | 25 +- .../payout-dialog.component.html | 113 +++- .../payout-dialog.component.scss | 20 + .../payout-dialog/payout-dialog.component.ts | 125 +++- .../premature-close-dialog.component.html | 75 ++- .../premature-close-dialog.component.scss | 20 + .../premature-close-dialog.component.ts | 96 ++- .../agent-master/agent-master.component.html | 17 +- .../agent-master/agent-master.component.ts | 23 +- .../create-agent/create-agent.component.html | 103 +++- .../create-agent/create-agent.component.scss | 45 ++ .../create-agent/create-agent.component.ts | 392 +++++++++++- .../agent-master/offices-under.ts | 27 + .../commission-slab-master.component.ts | 42 +- .../dds-batch-processing.component.ts | 26 +- src/app/organization/organization.service.ts | 69 +++ .../create-dds-account-type.component.html | 51 +- .../create-dds-account-type.component.ts | 40 +- .../dds-account-types.component.html | 14 +- src/app/products/products-routing.module.ts | 6 +- src/app/products/products.component.html | 6 +- .../checker-inbox-and-tasks.component.html | 4 + src/app/tasks/tasks-routing.module.ts | 6 + 48 files changed, 5878 insertions(+), 374 deletions(-) create mode 100644 src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html create mode 100644 src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss create mode 100644 src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts create mode 100644 src/app/deposits/dds-accounts/dds-accounts.component.html create mode 100644 src/app/deposits/dds-accounts/dds-accounts.component.scss create mode 100644 src/app/deposits/dds-accounts/dds-accounts.component.ts create mode 100644 src/app/deposits/dds-approvals/dds-approval-notice.ts create mode 100644 src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html create mode 100644 src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts create mode 100644 src/app/deposits/dds-approvals/dds-approvals.component.html create mode 100644 src/app/deposits/dds-approvals/dds-approvals.component.ts create mode 100644 src/app/deposits/party-master/client-search.ts create mode 100644 src/app/organization/agent-master/offices-under.ts diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 83efecf30..228a5357f 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -17,6 +17,8 @@ import { DueForClosureComponent } from '../deposits/party-master/due-for-closure import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component'; import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component'; import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component'; +import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component'; +import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component'; const routes: Routes = [ Route.withShell([ @@ -71,6 +73,19 @@ const routes: Routes = [ } ] }, + { + // The DDS accounts listed like Savings Accounts, each opening onto its transactions. + path: 'dds-accounts', + data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' }, + children: [ + { path: '', component: DdsAccountsComponent }, + { + path: ':partyId', + component: DdsAccountViewComponent, + data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false } + } + ] + }, { path: 'dds-collection', component: DailyTransactionEntryComponent, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index c6bc12365..61efec6d0 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -883,7 +883,11 @@ (click)="subLedgerOpen = false" > Suit File Entry - Locker Entry Recurring +
@@ -1184,7 +1196,12 @@ -
Reports
- - + diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html new file mode 100644 index 000000000..6de1335d7 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html @@ -0,0 +1,381 @@ + + +
+
+
+ account_balance_wallet +
+
+

Apply DDS Receipt

+

+ Agent {{ data.agentCode }} · {{ data.agentName }} + @if (data.glCode) { + · GL {{ data.glCode }} + } +

+
+ +
+ + + @if (loading) { +
+ + Loading entries and receipts… +
+ } @else { + +
+
+
+ Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }} + {{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle +
+
+ {{ required | number: '1.2-2' }} +
+
+
+
+ Vouchers picked + {{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }} +
+
+ + {{ received | number: '1.2-2' }} + + @if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) { + Short {{ -difference | number: '1.2-2' }} + } @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) { + Over {{ difference | number: '1.2-2' }} + } @else if (canApply) { + ✓ Matches + } +
+
+ +
+ Total to Apply +
+ {{ required | number: '1.2-2' }} + → + {{ received | number: '1.2-2' }} + + {{ canApply ? '✓ Ready' : 'Pending' }} + +
+
+
+ + +
+
+ 1 +
+

Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}

+

Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.

+
+
+ + @if (days.length) { +
    + @for (day of days; track day.date) { +
  • +
    + {{ day.date | date: 'dd/MM/yyyy' }} + {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} +
    + {{ day.total | number: '1.2-2' }} + +
  • + @if (openDay === day.date) { +
  • + @if (day.entries.length > 8) { + + } +
    + + + @for (entry of visibleEntries(day); track entry.id) { + + + + + + + } @empty { + + + + } + +
    {{ entry.partyCode }}{{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + {{ entry.amount | number: '1.2-2' }}
    No line matches.
    +
    +
  • + } + } +
+ } @else { +

+ {{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date + on the screen to the day the entries were written. +

+ } +
+ + +
+
+ 2 +
+

DDS Cash Receipts

+

Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.

+
+ +
+ + @if (available.length) { +
    + @for (option of available; track option.id) { +
  • + {{ + pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank' + }} +
    + {{ option.voucherNo }} + {{ option.txnDate }} · {{ glLabel(option) }} + @if (option.description) { + {{ option.description }} + } +
    + {{ option.amount | number: '1.2-2' }} + +
  • + } +
+ } @else { +

+ No DDS receipt is waiting on this agent's GL account. Collect the cash at + Cashier › Transaction Entry with entry type DDS and have the manager approve it, + or find an earlier voucher by number. +

+ } + + @if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) { +

+ The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{ + required | number: '1.2-2' + }}) exactly, so every customer is credited what the agent wrote down. +

+ } +
+ + @if (errorMsg && !searchOpen) { +
+ error_outline + {{ errorMsg }} +
+ } + } +
+ + + + + +
+ + +@if (searchOpen) { +
+ +
+} + + +@if (viewing) { +
+ +
+} diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss new file mode 100644 index 000000000..37ec50b55 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss @@ -0,0 +1,95 @@ +/** + * 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/. + */ + +// The same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was +// collected for, and the counter should meet one screen, not two. Used as it is, never changed here. +@use '../../../shares/share-dialog.shared' as *; + +:host { + display: block; + width: 100%; +} + +// The agent's outstanding lines - read only, what the receipt has to cover. +.entries { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + td { + padding: 7px 4px; + border-bottom: 1px solid #eef2f7; + } + + tr:last-child td { + border-bottom: none; + } + + .entry-date { + color: #64748b; + white-space: nowrap; + } + + .entry-code { + font-weight: 600; + white-space: nowrap; + } + + .entry-name { + color: #475569; + } + + .num { + text-align: right; + white-space: nowrap; + } +} + +// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio. +.option-check { + color: #94a3b8; + flex-shrink: 0; +} + +.option-picked { + border-color: #2563eb !important; + background: #eff6ff !important; + + .option-check { + color: #2563eb; + } +} + +// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the +// dialog a thousand lines tall. +.day-lines { + list-style: none; + padding: 6px 10px 10px 42px; +} + +.day-search { + width: 100%; + margin-bottom: 6px; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font: inherit; + font-size: 13px; +} + +.day-scroll { + max-height: 240px; + overflow-y: auto; +} + +.field-readonly { + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; +} diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts new file mode 100644 index 000000000..1993d96ab --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts @@ -0,0 +1,351 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; + +/** One day of an agent's outstanding entries, as the dialog lists them. */ +export interface EntryDay { + date: string; + entries: any[]; + total: number; +} + +/** + * Applies the cashier's DDS receipts to an agent's outstanding collections. + * + * The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL + * account, tagged DDS, and the manager approved it. Applying it here hands the money on to the + * customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is + * settled against the application. The same screen as Apply Additional Shares. + * + * Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick, + * because what the agent wrote down is settled whole, and an agent who comes in after several days + * brings several days' cash. The vouchers are ticked, since a day's + * cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what + * each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave + * money nobody can place. + * + * The day is shown totalled and its lines opened only when asked for - an agent with a thousand + * customers is a thousand lines, and the day's total is what the counter is checking. + */ +@Component({ + selector: 'mifosx-apply-dds-receipt-dialog', + templateUrl: './apply-dds-receipt-dialog.component.html', + styleUrls: ['./apply-dds-receipt-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class ApplyDdsReceiptDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + public dialogRef = inject(MatDialogRef); + public data = inject<{ + agentId: number; + agentCode: string; + agentName: string; + glCode: string | null; + entryDate: string; + }>(MAT_DIALOG_DATA); + + loadingEntries = true; + loadingReceipts = true; + submitting = false; + errorMsg = ''; + + /** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */ + days: EntryDay[] = []; + + /** DDS receipts on the agent's GL account, approved and not yet applied. */ + available: any[] = []; + + /** + * The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none + * is picked for them even when only one is listed. + */ + pickedDays = new Set(); + pickedReceipts = new Set(); + + /** The day whose lines are open, and the search within it. */ + openDay: string | null = null; + daySearch = ''; + + get loading(): boolean { + return this.loadingEntries || this.loadingReceipts; + } + + ngOnInit(): void { + // Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for + // several days arrives in one visit, and all of it is settled together. + this.organizationService + .getAgentPendingCollections(this.data.agentId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, "Could not load the agent's entries."); + return of(null); + }) + ) + .subscribe((res: any) => { + this.days = groupByDay( + (res?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate + ) + ); + this.pickedDays = new Set(this.days.map((day) => day.date)); + this.loadingEntries = false; + }); + + this.organizationService + .getAgentCashReceipts(this.data.agentId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Could not load the available receipts.'); + return of([]); + }) + ) + .subscribe((rows: any) => { + this.available = Array.isArray(rows) ? rows : []; + this.loadingReceipts = false; + }); + } + + // ── Picking ───────────────────────────────────────────────────────────────────────────────── + + toggleReceipt(receipt: any): void { + if (this.submitting) { + return; + } + this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id); + } + + toggleOpen(day: EntryDay, event: Event): void { + event.stopPropagation(); + this.openDay = this.openDay === day.date ? null : day.date; + this.daySearch = ''; + } + + /** The open day's lines, narrowed by party code or name. */ + visibleEntries(day: EntryDay): any[] { + const term = this.daySearch.trim().toLowerCase(); + if (!term) { + return day.entries; + } + return day.entries.filter( + (entry: any) => + String(entry.partyCode || '') + .toLowerCase() + .includes(term) || + String(entry.partyName || '') + .toLowerCase() + .includes(term) + ); + } + + // ── What has to be covered ────────────────────────────────────────────────────────────────── + + get entryCount(): number { + return this.days.reduce((sum, day) => sum + day.entries.length, 0); + } + + get outstandingTotal(): number { + return this.days.reduce((sum, day) => sum + day.total, 0); + } + + get pickedEntryCount(): number { + return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0); + } + + get required(): number { + return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0)); + } + + get received(): number { + return round2( + this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0) + ); + } + + /** Vouchers less entries: below zero they are short, above zero they are over. */ + get difference(): number { + return round2(this.received - this.required); + } + + get canApply(): boolean { + return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting; + } + + glLabel(option: any): string { + return [ + option?.glCode, + option?.glName + ] + .filter(Boolean) + .join(' — '); + } + + // ── Voucher search, for a receipt the list does not carry ─────────────────────────────────── + + searching = false; + searchOpen = false; + searchVoucherNo = ''; + + openSearch(): void { + this.searchOpen = true; + this.searchVoucherNo = ''; + this.errorMsg = ''; + } + + closeSearch(): void { + this.searchOpen = false; + this.errorMsg = ''; + } + + /** + * Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as + * DDS, not approved yet, or already applied - rather than simply not finding it. + */ + runSearch(): void { + const voucher = this.searchVoucherNo.trim(); + if (!voucher || this.searching) { + return; + } + this.searching = true; + this.errorMsg = ''; + this.organizationService + .getAgentCashReceipts(this.data.agentId, voucher) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`); + return of(null); + }) + ) + .subscribe((rows: any) => { + this.searching = false; + if (rows === null) { + return; + } + const found = (Array.isArray(rows) ? rows : [])[0]; + if (!found) { + this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`; + } else if (found.entryType !== 'DDS') { + this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`; + } else if (found.status !== 'APPROVED') { + this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`; + } else if (found.applied) { + this.errorMsg = `Voucher ${voucher} has already been applied.`; + } else { + this.available = [ + found, + ...this.available.filter((entry) => entry.id !== found.id) + ]; + this.pickedReceipts.add(found.id); + this.searchOpen = false; + } + }); + } + + // ── A receipt's own GL entries ────────────────────────────────────────────────────────────── + + viewing: any = null; + viewingGlEntries: any[] | null = null; + loadingGlEntries = false; + + openDetail(option: any, event: Event): void { + event.stopPropagation(); + this.viewing = option; + this.viewingGlEntries = null; + this.loadingGlEntries = true; + this.organizationService + .getCashReceiptGlEntries(option.id) + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + this.viewingGlEntries = Array.isArray(rows) ? rows : []; + this.loadingGlEntries = false; + }); + } + + closeDetail(): void { + this.viewing = null; + this.viewingGlEntries = null; + } + + // ── Submission ────────────────────────────────────────────────────────────────────────────── + + apply(): void { + if (!this.canApply) { + return; + } + this.submitting = true; + this.errorMsg = ''; + const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo); + const entries = this.pickedEntryCount; + const amount = this.required; + this.organizationService + .settleAgentCollections({ + agentId: Number(this.data.agentId), + cashierDepositRequestIds: Array.from(this.pickedReceipts), + entryDates: Array.from(this.pickedDays), + locale: this.settingsService.language.code + }) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'The receipts could not be applied.'); + this.submitting = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.dialogRef.close({ vouchers, entries, amount, response: res }); + } + }); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.defaultUserMessage ?? + err?.error?.developerMessage ?? + fallback + ); + } +} + +/** Entries grouped by the day they were collected, oldest first, each day totalled. */ +export function groupByDay(entries: any[]): EntryDay[] { + const byDay = new Map(); + for (const entry of entries) { + const date = String(entry.entryDate); + const day = byDay.get(date) ?? { date, entries: [], total: 0 }; + day.entries.push(entry); + day.total = round2(day.total + Number(entry.amount || 0)); + byDay.set(date, day); + } + return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html index 169f2550f..e8ae7d13b 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html @@ -79,8 +79,8 @@ } @else {

- Pick a party in a collection row below — this panel shows whose account it is and what - the balance becomes. + Pick a party in a collection row below — this panel shows whose account it is and what the balance + becomes.

}
@@ -121,7 +121,7 @@ required [matDatepicker]="txnDatePicker" formControlName="transactionDate" - (dateChange)="loadPendingEntries()" + (dateChange)="loadPendingEntries(); loadAgentMoney()" /> @@ -132,7 +132,10 @@ @for (type of transactionTypes; track type.label) { - {{ type.label }}@if (!type.supported) { — not available yet} + {{ type.label }} + @if (!type.supported) { + — not available yet + } } @@ -151,7 +154,7 @@
-

Collections to settle

+

Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}

@@ -160,15 +163,25 @@ @if (pendingLoading) {

Reading this agent's entries...

} @else if (pendingEntries.length === 0) { -

- {{ agentInfo.name }} has written nothing down for this date. Nothing to settle. -

+

{{ agentInfo.name }} has nothing outstanding up to this date.

} @else { @for (entry of pendingEntries; track entry.id) {
+ + {{ entry.entryDate | dateFormat }} {{ entry.partyCode }} {{ entry.partyName }} - {{ entry.transactionMode }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + {{ entry.amount | number: '1.2-2' }}
} @@ -181,49 +194,145 @@ -

- Take {{ pendingTotal | number: '1.2-2' }} from {{ agentInfo.name }}, then post. +

+ Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by + customer. The cash and its receipts are under Agent Tran.

}
} - @if (isAgentTran) { + + @if (isAgentTran && agentInfo.name) {
-

Agent Settlement

+

Agent's cash

+ +
-

- Cash the agent hands over to the branch, deducted from the agent's collected balance shown above. -

-
- - Amount - - - - TRTP - - @for (mode of transactionModes; track mode) { - {{ mode }} - } - - - - AC Type - - - - A/C No. - - - - Description - - + + @if (!agentGlCode) { +

+ {{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent + Master. +

+ } @else { + +

+ Agent GL: {{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }} — the cashier + takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS). +

+ } + +
+
+ To take + {{ agentOwed | number: '1.2-2' }} +
+
+ Received (approved) + {{ agentReceived | number: '1.2-2' }} +
+
+ Still to take + {{ agentBalance | number: '1.2-2' }} +
+ @if (agentReceivedBeyondDay > 0) { +

+ {{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding + up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the + right agent. +

+ } + @if (agentWaiting > 0) { +

+ {{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval. +

+ } + +

+ Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }} +

+ @if (agentMoneyLoading) { +

Reading this agent's entries...

+ } @else if (agentDays.length === 0) { +

{{ agentInfo.name }} has nothing outstanding.

+ } @else { + @for (day of agentDays; track day.date) { +
+ {{ day.date | dateFormat }} + + {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} + + {{ day.total | number: '1.2-2' }} + +
+ @if (agentOpenDay === day.date) { +
+ @for (entry of day.entries; track entry.id) { +
+ {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + + {{ entry.amount | number: '1.2-2' }} +
+ } +
+ } + } + } + +

Cashier receipts

+ @if (agentReceipts.length === 0) { +

+ No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to + be applied. +

+ } @else { + @for (receipt of agentReceipts; track receipt.id) { +
+ {{ receipt.voucherNo }} + {{ receipt.txnDate | dateFormat }} + {{ receipt.cashierName || '—' }} + + {{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }} + + {{ receipt.amount | number: '1.2-2' }} +
+ } + }
} @@ -236,28 +345,80 @@
Creditor Party Code - + + arrow_drop_down + + @for (party of transferPartyOptions('creditor'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('creditor').length === 0) { + No open DDS account matches + } + + Account the money goes into Creditor Party Name - + Creditor Curr Bal - + Debit Party Code - + + arrow_drop_down + + @for (party of transferPartyOptions('debit'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('debit').length === 0) { + No open DDS account matches + } + + @if (transferToItself) { + Pick a different account - it cannot transfer to itself + } @else { + Account the money is taken from + } Debit Party Name - + Debit Curr Bal - + @@ -287,17 +448,20 @@ - - + + @if (!isPartyTran && !isAgentTran) { + + }
diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss index 6255bc5fa..5a05ca32b 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -160,13 +160,14 @@ $primary: #0f766e; .dte-collection-row { display: grid; grid-template-columns: - minmax(0, 2.2fr) /* Party */ - minmax(0, 1fr) /* TRTP */ - minmax(0, 1fr) /* AC Type */ - minmax(0, 1.4fr) /* A/C No. */ - minmax(0, 1fr) /* Amount */ - minmax(0, 1.6fr) /* Description */ - auto; /* remove */ + minmax(0, 2.2fr) /* Party */ + minmax(0, 1fr) /* TRTP */ + minmax(0, 1fr) /* AC Type */ + minmax(0, 1.4fr) /* A/C No. */ + minmax(0, 1fr) /* Amount */ + minmax(0, 1.6fr) /* Description */ + auto; /* remove */ + gap: 8px 12px; align-items: start; padding: 10px 0; @@ -275,7 +276,6 @@ $primary: #0f766e; color: rgb(0 0 0 / 45%); } - /* * Marks a party this agent has already collected from today, on the row where it is picked. * @@ -334,7 +334,6 @@ $primary: #0f766e; font-weight: 500; } - /* The agent's written-down round, as the counter reads it. @@ -347,12 +346,19 @@ $primary: #0f766e; align-items: baseline; gap: 12px; padding: 10px 2px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); + border-bottom: 1px solid rgb(0 0 0 / 8%); &:last-of-type { border-bottom: none; } + /* First, and in the quieter weight: the date is context for the line, not what is being read. */ + &__date { + min-width: 96px; + opacity: 0.7; + font-variant-numeric: tabular-nums; + } + &__code { font-weight: 600; min-width: 84px; @@ -393,3 +399,90 @@ $primary: #0f766e; font-variant-numeric: tabular-nums; } } + +/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */ +.dte-expected--note { + font-size: 13px; + opacity: 0.65; + margin-top: 4px; +} + +.dte-apply-receipt { + margin-left: 8px; +} + +/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */ +.dte-money { + display: flex; + gap: 12px; + margin: 8px 0 12px; + flex-wrap: wrap; + + &__box { + flex: 1; + min-width: 160px; + padding: 10px 14px; + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; + font-variant-numeric: tabular-nums; + + strong { + font-size: 18px; + } + + &--due { + border-color: rgb(198 40 40 / 40%); + background: rgb(198 40 40 / 4%); + } + } + + &__label { + font-size: 12px; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +.dte-subhead { + margin: 16px 0 4px; + font-size: 14px; + font-weight: 600; +} + +.dte-pending-row--day { + cursor: pointer; +} + +.dte-day-lines { + margin-left: 24px; + max-height: 280px; + overflow-y: auto; + border-left: 3px solid rgb(25 118 210 / 30%); + padding-left: 10px; +} + +.dte-status { + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + background: rgb(237 108 2 / 12%); + color: #b45309; + + &--ok { + background: rgb(46 125 50 / 12%); + color: #2e7d32; + } +} + +.dte-list-arrow { + cursor: pointer; +} + +.dte-same-party { + color: #c62828; + font-weight: 600; +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts index 92d8bcaaa..dce4d07d8 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -12,17 +12,25 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } fr import { Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatIconModule } from '@angular/material/icon'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; /** Custom Components */ import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; +import { + ApplyDdsReceiptDialogComponent, + EntryDay, + groupByDay +} from './apply-dds-receipt-dialog/apply-dds-receipt-dialog.component'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; /** * Daily Transaction Entry component. @@ -45,7 +53,11 @@ import { Dates } from 'app/core/utils/dates'; selector: 'mifosx-daily-transaction-entry', templateUrl: './daily-transaction-entry.component.html', styleUrls: ['./daily-transaction-entry.component.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatAutocompleteModule, + MatIconModule + ] }) export class DailyTransactionEntryComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); @@ -301,9 +313,11 @@ export class DailyTransactionEntryComponent implements OnInit { row.get('amount')?.setValue(picked.amount); row.get('advanceDays')?.setValue(picked.advanceDays ?? ''); row.get('arrearsDays')?.setValue(picked.arrearsDays ?? ''); - row.get('description')?.setValue( - `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` - ); + row + .get('description') + ?.setValue( + `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` + ); this.recalculateRowBalance(this.collections.length - 1); }); } @@ -373,10 +387,11 @@ export class DailyTransactionEntryComponent implements OnInit { } /** - * Reads what this agent has outstanding for the date on the form. + * Reads everything this agent has outstanding up to the date on the form, customer by customer. * - * Asked again whenever the agent or the date changes, because both pick out a different day's - * round - and a list left over from the previous choice is a list the clerk might settle. + * Not only that day's: what was written on earlier days and not yet handed in is still owed, and a + * clerk checking the round should see it in one list rather than open every date in turn. Each line + * carries its own date. Asked again whenever the agent or the date changes. */ loadPendingEntries(): void { const agentId = this.entryForm.get('agentId')?.value; @@ -387,10 +402,12 @@ export class DailyTransactionEntryComponent implements OnInit { } this.pendingLoading = true; - const on = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); - this.organizationService.getAgentCollectionDay(agentId, on).subscribe({ + const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentPendingCollections(agentId).subscribe({ next: (summary: any) => { - this.pendingEntries = (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING'); + this.pendingEntries = (summary?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo + ); this.pendingLoading = false; }, error: () => { @@ -400,6 +417,138 @@ export class DailyTransactionEntryComponent implements OnInit { }); } + /** The picked agent's own GL account code - where the cashier receipts their cash. */ + agentGlCode: string | null = null; + agentGlName: string | null = null; + + // ── Agent Tran: the agent's money, owed and received ────────────────────────────────────── + // + // The agent's cash reaches the bank at the cashier's counter, never on this screen. What Agent Tran + // shows is where that stands: what the agent owes on everything they wrote down, what the cashier has + // taken in against it, and what is left - and from here the receipts are applied to the customers. + + /** What the agent still has outstanding up to the date on the form, a day at a time. */ + agentDays: EntryDay[] = []; + + /** DDS receipts on the agent's GL account not yet applied, including those still with the manager. */ + agentReceipts: any[] = []; + + agentMoneyLoading = false; + + /** The day whose lines are open in the Agent Tran list. */ + agentOpenDay: string | null = null; + + get agentOwed(): number { + return this.agentDays.reduce((sum, day) => sum + day.total, 0); + } + + get agentReceived(): number { + return this.agentReceipts.filter((r) => r.status === 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + get agentWaiting(): number { + return this.agentReceipts.filter((r) => r.status !== 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + /** + * What is still to be taken from the agent for this date - never below nothing. The receipts are + * not tied to a date (cash often reaches the counter the day after the round), so more received than + * this date's entries is not a debt the other way; it is cash for another day, said so separately. + */ + get agentBalance(): number { + return Math.max(0, Math.round((this.agentOwed - this.agentReceived) * 100) / 100); + } + + /** Received cash beyond this date's entries - most likely for another day's. */ + get agentReceivedBeyondDay(): number { + return Math.max(0, Math.round((this.agentReceived - this.agentOwed) * 100) / 100); + } + + loadAgentMoney(): void { + const agentId = this.entryForm.get('agentId')?.value; + const date = this.entryForm.get('transactionDate')?.value; + this.agentOpenDay = null; + if (!agentId || !date) { + this.agentDays = []; + this.agentReceipts = []; + return; + } + this.agentMoneyLoading = true; + // Everything still outstanding up to the date on the screen, whatever day it was written: an agent + // who comes in after three days brings three days' cash, and the counter has to see all of it to + // know what to take. + const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentPendingCollections(agentId).subscribe({ + next: (summary: any) => { + this.agentDays = groupByDay( + (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo) + ); + this.agentMoneyLoading = false; + }, + error: () => { + this.agentDays = []; + this.agentMoneyLoading = false; + } + }); + this.organizationService.getAgentCashReceipts(agentId, undefined, true).subscribe({ + next: (rows: any[]) => (this.agentReceipts = rows || []), + error: () => (this.agentReceipts = []) + }); + } + + toggleAgentDay(date: string): void { + this.agentOpenDay = this.agentOpenDay === date ? null : date; + } + + /** + * Opens Apply DDS Receipt: the cashier's DDS receipt on this agent's GL account, applied to what the + * agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays + * what it is - what to take from the agent - and the applying is done in one deliberate place. + */ + openApplyReceipt(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return; + } + const picked = this.agents.find((agent: any) => agent.id === agentId); + this.dialog + .open(ApplyDdsReceiptDialogComponent, { + width: '780px', + maxWidth: '96vw', + panelClass: 'apply-collection-dialog-panel', + data: { + agentId, + agentCode: picked?.agentCode || '', + agentName: this.agentInfo.name || picked?.agentNameId || '', + glCode: this.agentGlCode, + // The day chosen on the screen: its entries are what the receipts settle. + entryDate: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd') + } + }) + .afterClosed() + .subscribe((result: any) => { + if (!result) { + return; + } + const money = Number(result.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const vouchers: string[] = result.vouchers || []; + if (heldForApproval(result.response)) { + announceHeldForApproval(result.response, `Applying ${money}`, this.organizationService, this.alertService); + this.loadPendingEntries(); + return; + } + this.alertService.alert({ + type: 'Success', + message: + `${vouchers.length === 1 ? 'Voucher' : 'Vouchers'} ${vouchers.join(', ')} applied - ` + + `${result.entries} ${result.entries === 1 ? 'entry' : 'entries'}, ${money}` + }); + this.loadPendingEntries(); + this.loadTodaysEntries(); + this.loadAgentMoney(); + }); + } + get isPartyTran(): boolean { return this.entryForm.get('transactionType').value === 'Party Tran'; } @@ -487,6 +636,113 @@ export class DailyTransactionEntryComponent implements OnInit { next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)), error: () => (this.agents = []) }); + + // Withdraw Deposite: the open accounts either side can be picked from, and a code typed or picked + // fills in whose account it is and what it holds, so the clerk checks the transfer against both. + this.organizationService.getParties().subscribe({ + next: (parties: any) => { + const all = Array.isArray(parties) ? parties : parties?.pageItems || []; + this.openParties = all.filter((party: any) => party.status === 'ACTIVE'); + this.fillTransferParty('creditor'); + this.fillTransferParty('debit'); + }, + error: () => (this.openParties = []) + }); + this.entryForm.get('creditorPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('creditor')); + this.entryForm.get('debitPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('debit')); + } + + /** Open DDS accounts, for the two sides of a Withdraw Deposite. */ + openParties: any[] = []; + + /** + * The open accounts either side can be picked from. The server moves money only between two + * accounts of the agent on the entry, so once an agent is chosen only that agent's accounts are offered. + */ + private get transferableParties(): any[] { + const agentId = Number(this.entryForm.get('agentId')?.value) || null; + return agentId ? this.openParties.filter((party: any) => Number(party.agentId) === agentId) : this.openParties; + } + + /** The open accounts matching what has been typed on one side - by code, name or account number, any case. */ + transferPartyOptions(side: 'creditor' | 'debit'): any[] { + const typed = String(this.entryForm.get(side + 'PartyCode')?.value || '') + .trim() + .toLowerCase(); + const other = String(this.entryForm.get(this.otherSide(side) + 'PartyCode')?.value || '') + .trim() + .toUpperCase(); + // Once an account is picked the whole list comes back, so another can be chosen without clearing the box. + const picked = this.transferableParties.some((party: any) => String(party.partyCode).toLowerCase() === typed); + return this.transferableParties + .filter((party: any) => String(party.partyCode).toUpperCase() !== other) + .filter( + (party: any) => + !typed || + picked || + String(party.partyCode).toLowerCase().includes(typed) || + String(party.partyName || '') + .toLowerCase() + .includes(typed) || + String(party.accountNo || '') + .toLowerCase() + .includes(typed) + ) + .slice(0, 50); + } + + /** True when the same account has been put on both sides. */ + get transferToItself(): boolean { + const creditor = String(this.entryForm.get('creditorPartyCode')?.value || '') + .trim() + .toUpperCase(); + const debit = String(this.entryForm.get('debitPartyCode')?.value || '') + .trim() + .toUpperCase(); + return this.isWithdrawDeposite && !!creditor && creditor === debit; + } + + /** + * When the box is left with a name or part of one typed in and only one account matches it, that + * account is taken - so typing "amit" and moving on picks Amit's account without opening the list. + */ + settleTransferParty(side: 'creditor' | 'debit'): void { + const control = this.entryForm.get(side + 'PartyCode'); + const typed = String(control?.value || '').trim(); + if (!typed || this.partyByCode(typed)) { + return; + } + const matches = this.transferPartyOptions(side); + if (matches.length === 1) { + control?.setValue(matches[0].partyCode); + } + } + + private otherSide(side: 'creditor' | 'debit'): 'creditor' | 'debit' { + return side === 'creditor' ? 'debit' : 'creditor'; + } + + private partyByCode(code: string): any { + const wanted = String(code || '') + .trim() + .toUpperCase(); + return wanted ? this.openParties.find((each: any) => String(each.partyCode).toUpperCase() === wanted) : undefined; + } + + /** + * Fills a side's name and balance from the code on it, and clears them when it matches no account. + * The code is matched in any case and put back as the account has it, so "pt0002" becomes PT0002. + */ + private fillTransferParty(side: 'creditor' | 'debit'): void { + const control = this.entryForm.get(side + 'PartyCode'); + const party = this.partyByCode(control?.value); + if (party && control?.value !== party.partyCode) { + control?.setValue(party.partyCode, { emitEvent: false }); + } + this.entryForm.get(side + 'PartyName')?.setValue(party ? party.partyName : '', { emitEvent: false }); + this.entryForm + .get(side + 'PartyBalance') + ?.setValue(party ? Number(party.currentBalance || 0) : 0, { emitEvent: false }); } /** @@ -527,6 +783,9 @@ export class DailyTransactionEntryComponent implements OnInit { if (!agentId) { this.agentParties = []; this.pendingEntries = []; + this.agentGlCode = null; + this.agentDays = []; + this.agentReceipts = []; return; } @@ -542,10 +801,20 @@ export class DailyTransactionEntryComponent implements OnInit { guarantor2: guarantors[1]?.guarantorName || '', currentBalance: agent.agentCurrentBalance || 0 }; + this.agentGlCode = agent.glAccountCode || null; + this.agentGlName = agent.glAccountName || null; }, error: () => { this.agentLookupFailed = true; - this.agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 }; + this.agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; } }); @@ -559,6 +828,7 @@ export class DailyTransactionEntryComponent implements OnInit { this.loadTodaysEntries(); this.loadPendingEntries(); + this.loadAgentMoney(); } /** This agent's collections already recorded for the date on the screen. */ @@ -574,8 +844,8 @@ export class DailyTransactionEntryComponent implements OnInit { next: (transactions: any[]) => { this.todaysEntries = (transactions || []).filter( (transaction: any) => - transaction.transactionType === 'PARTY_TRAN' - && this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate + transaction.transactionType === 'PARTY_TRAN' && + this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate ); }, error: () => (this.todaysEntries = []) @@ -672,36 +942,18 @@ export class DailyTransactionEntryComponent implements OnInit { * warn about. */ save(): void { - if (!this.isPartyTran) { - this.postTransaction(); + // A Party Tran cannot be saved from here at all. The screen shows the agent's own entries so + // the cash can be counted against them; taking that cash in is done where the branch takes cash + // in, and two ways to credit the same customers is one way too many. The button is not rendered + // for this type either - this is the same rule said again where it cannot be bypassed. + // Agent Tran is not saved from here either any more. The agent's cash is taken in at the cashier's + // counter, and a second way to take the same cash in is one way too many; Agent Tran shows where it + // stands and applies the cashier's receipts. + if (this.isPartyTran || this.isAgentTran) { return; } - if (this.pendingEntries.length === 0) { - return; - } - - const money = Number(this.pendingTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 }); - const count = this.pendingEntries.length; - - this.dialog - .open(ConfirmationDialogComponent, { - data: { - type: 'warn', - heading: 'Settle these collections', - dialogContext: - `Take ${money} from ${this.agentInfo.name || 'the agent'} and post ` - + `${count} ${count === 1 ? 'collection' : 'collections'}? ` - + 'Every customer on the list is credited and the cash is recorded against the agent. ' - + 'This cannot be undone.' - } - }) - .afterClosed() - .subscribe((response: any) => { - if (response?.confirm) { - this.postTransaction(); - } - }); + this.postTransaction(); } private postTransaction(): void { @@ -743,10 +995,17 @@ export class DailyTransactionEntryComponent implements OnInit { // What was just posted, captured before the form is cleared, so the confirmation can name it. const posted = this.postedSummary(raw); - this.organizationService.createPartyTransaction(payload).subscribe(() => { + this.organizationService.createPartyTransaction(payload).subscribe((response: any) => { // On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk // who looks away reasonably assumes it did not go through and posts the whole round again. - this.alertService.alert({ type: 'Success', message: posted }); + // + // With maker-checker on, the request comes back with a command id and nothing done: it is + // waiting for a checker, and saying it was posted would be telling the clerk the money moved. + const awaitingApproval = !!response?.commandId && response?.resourceId == null; + this.alertService.alert({ + type: 'Success', + message: awaitingApproval ? `Sent for approval: ${posted}. Nothing moves until a checker approves it.` : posted + }); this.collections.clear(); this.partyInfo = null; @@ -794,8 +1053,18 @@ export class DailyTransactionEntryComponent implements OnInit { dateFormat: 'yyyy-MM-dd', locale: this.settingsService.language.code }) - .subscribe(() => { + .subscribe((response: any) => { const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${count} ${count === 1 ? 'collection' : 'collections'} for ${agent} (${money})`, + this.organizationService, + this.alertService + ); + this.loadPendingEntries(); + return; + } this.alertService.alert({ type: 'Success', message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html new file mode 100644 index 000000000..888501fe1 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html @@ -0,0 +1,557 @@ + + +
+ @if (isLoading) { +
+ + Loading account… +
+ } @else if (!account) { +
+ + DDS account not found + +
+ } @else { + +
+
+
+ ● {{ account.status }} +

{{ account.subType }}

+
+ Account Number + {{ account.accountNo }} + Client Name + {{ account.clientName || account.partyName }} +
+
+
+
+ Current Balance + ₹{{ account.currentBalance || 0 | formatNumber }} +
+
+ Maturity Amount + ₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+
+ + +
+
+ + +
+ + + + + @if (account.status === 'CLOSED') { + + } +
+ + + @if (tab === 'closure') { + @if (!closure) { +
+
Loading closure details…
+
+ } @else { +
+
+
+
How It Closed
+
+ Type + + {{ closureLabels[closure.closureType] || closure.closureType }} + @if (closure.withinLockin) { + within lock-in + } + +
+
+ Closed On{{ closure.closeDate | date: 'dd MMMM yyyy' }} +
+ @if (closure.paidOutBy) { +
+ Paid Out By{{ closure.paidOutBy }} +
+ } +
+ Closed By{{ closure.closedBy || '—' }} +
+
+ Approved By{{ closure.approvedBy || '—' }} +
+ @if (closure.lockinOverrideReason) { +
+ Reason{{ closure.lockinOverrideReason }} +
+ } + @if (closure.shortfallBorneBy) { +
+ Cost Borne By + {{ + closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer' + }} +
+ } +
+ +
+
The Money
+
+ Balance at Close₹{{ closure.balanceAtClose || 0 | formatNumber }} +
+ @if (closure.interestRecovered > 0) { +
+ Interest Recovered− ₹{{ closure.interestRecovered | formatNumber }} +
+ } + @if (closure.interestToppedUpByAgent > 0) { +
+ Interest Funded by Agent+ ₹{{ closure.interestToppedUpByAgent | formatNumber }} +
+ } +
+ Paid Out₹{{ closure.paidOut || 0 | formatNumber }} +
+ @if (closure.closureType === 'PREMATURE') { +
+ Agent Penalty ({{ closure.agentCode }})₹{{ closure.agentCommissionPenalty || 0 | formatNumber }} +
+ } +
+
+ +
+
+
Where It Went
+ @if (closure.destination === 'OWN_ACCOUNT') { +
Paid IntoCustomer's own account
+
+ Account{{ closure.creditedAccount?.accountNo }} +
+ @if (closure.creditedAccount?.productName) { +
+ Product{{ closure.creditedAccount.productName }} +
+ } + @if (closure.creditedAccount?.owner) { +
+ Holder{{ closure.creditedAccount.owner }} +
+ } + } @else if (closure.destination === 'SUNDRY') { +
Parked InSundry (customer has no account here)
+
+ GL Account{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }} +
+ } @else { +
Paid OutNothing - the balance was nil
+ } + @if (closure.voucherNo) { +
+ Voucher{{ closure.voucherNo }} +
+ } + @if (closure.payoutDate) { +
+ Paid On{{ closure.payoutDate | date: 'dd MMMM yyyy' }} +
+ } + @if (closure.passbookNote) { +
+ Passbook Note{{ closure.passbookNote }} +
+ } +
+ + @if (closure.ledgerEntries?.length) { +
+
Ledger Entry
+
+ Transaction ID{{ closure.ledgerTransactionId }} +
+ @for (leg of closure.ledgerEntries; track $index) { +
+ {{ leg.glCode }} · {{ leg.glName }} + + {{ leg.side }} ₹{{ leg.amount | formatNumber }} + +
+ } +
+ } +
+
+ } + } + + + @if (tab === 'general') { +
+
+
+
DDS Details
+
+ Account Number{{ account.accountNo }} +
+
+ Party Code{{ account.partyCode }} +
+
+ Client Name{{ account.clientName }} ({{ account.clientId }}) +
+
+ Agent{{ account.agentCode }} · {{ account.agentName }} +
+
+ Branch{{ account.officeName }} +
+
+ Product{{ account.subType }} +
+
+ Opened On{{ account.openingDate | date: 'dd MMMM yyyy' }} +
+
+ Matures On{{ account.maturityDate | date: 'dd MMMM yyyy' }} +
+
+ Daily Deposit₹{{ account.dailyDepositAmount || 0 | formatNumber }} +
+
+ Term{{ account.month }} months · {{ account.days }} days +
+
+ Interest Rate{{ account.interestRate }} % +
+
+ CurrencyIndian Rupee [INR] +
+
+ +
+
Performance History
+
+ Total Collected₹{{ totalOf('PARTY_TRAN') | formatNumber }} +
+
+ Total Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Total Paid Out₹{{ totalDebitsAll | formatNumber }} +
+
+
+ +
+
+
Account Summary
+
+ Collected to Date₹{{ account.collectedToDate || 0 | formatNumber }} +
+
+ Expected to Date₹{{ account.expectedToDate || 0 | formatNumber }} +
+
+ Standing + + @if (account.advanceDays > 0) { + {{ account.advanceDays }} days ahead + } @else if (account.arrearsDays > 0) { + {{ account.arrearsDays }} days behind + } @else { + Up to date + } + +
+
+ Instalments Due{{ account.instalmentsDue ?? 0 }} +
+
+ Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Interest Calculated, Not Posted₹{{ account.interestCalc || 0 | formatNumber }} +
+
Interest Calculated UsingDaily Balance
+
Days in Year365 Days
+
+ Last Interest Date{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Last Transaction Date{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Maturity Amount₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+ Lien + {{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }} +
+
+ Freeze{{ account.freezeCode || 'Normal' }} +
+ @if (account.closeDate) { +
+ Closed On{{ account.closeDate | date: 'dd MMMM yyyy' }} +
+ } +
+
+
+ } + + + @if (tab === 'transactions') { +
+
+
+ From + +
+
+ To + +
+ + @if (hasDateFilters) { + + } +
+ Credits ₹{{ totalCredits | formatNumber }} + Debits ₹{{ totalDebits | formatNumber }} +
+ +
+ + @if (shownTransactions.length === 0) { +
+ + {{ + hasDateFilters + ? 'Nothing moved on this account in these dates' + : 'Nothing has been credited to this account yet' + }} +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (txn of shownTransactions; track txn.id; let i = $index) { + + + + + + + + + + + + } + +
#IDTransaction DateTransaction TypeVoucherDescriptionDebitCreditBalance
{{ i + 1 }}{{ txn.id }}{{ txn.transactionDate | date: 'dd MMMM yyyy' }}{{ label(txn.transactionType) }}{{ txn.voucherNo || '—' }}{{ txn.description }} + {{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + {{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + ₹{{ txn.balanceAfterTransaction || 0 | formatNumber }} +
+
+ } +
+ } + + + @if (tab === 'passbook') { +
+
+
+ From + +
+
+ To + +
+ @if (hasDateFilters) { + + } + +
+ +
+
+
+
DDS Passbook
+
{{ account.officeName }}
+
+
+ Account No{{ account.accountNo }} Party Code{{ account.partyCode }} Name{{ account.clientName || account.partyName }} Product{{ account.subType }} · {{ account.interestRate }} % Opened On{{ account.openingDate | date: 'dd/MM/yyyy' }} Agent{{ account.agentCode }} +
+
+ + @if (passbookEntries.length === 0) { +
+ {{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }} +
+ } @else { + + + + + + + + + + + + + @for (txn of passbookEntries; track txn.id) { + + + + + + + + + } + + + + + + + + + +
DateVoucherParticularsWithdrawalsDepositsBalance
{{ txn.transactionDate | date: 'dd/MM/yyyy' }}{{ txn.voucherNo || '' }} + {{ label(txn.transactionType) }} + @if (txn.description) { + {{ txn.description }} + } + {{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }} + {{ txn.balanceAfterTransaction || 0 | formatNumber }} +
Total + {{ totalDebits | formatNumber }} + + {{ totalCredits | formatNumber }} + + {{ passbookClosingBalance | formatNumber }} +
+ } +
+
+ } + + + @if (tab === 'nominees') { +
+ @if (!account.nominees || account.nominees.length === 0) { +
+ + No nominee on this account +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (nominee of account.nominees; track nominee.id; let i = $index) { + + + + + + + + + + + + } + +
#NameRelationGenderMobileBirth DateNominated OnGuardian{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}
{{ i + 1 }} + {{ nominee.name }} + {{ nominee.relation }}{{ nominee.gender }}{{ nominee.mobileNo || '—' }}{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.guardianName || '—' }} + {{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }} +
+
+ } +
+ } + } +
diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss new file mode 100644 index 000000000..d0da71aa8 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss @@ -0,0 +1,459 @@ +/** + * 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/. + */ + +/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */ + +.dav-page { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px 32px; +} + +.dav-mono { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-style: normal; +} + +.dav-cr { + color: #047857; +} + +.dav-dr { + color: #b91c1c; +} + +.dav-right { + text-align: right !important; +} + +/* ── Banner ───────────────────────────────────────────────────────────── */ + +.dav-banner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding: 16px 24px; + border-radius: 16px; + color: #fff; + background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%); + box-shadow: 0 8px 24px rgb(30 58 138 / 25%); +} + +.dav-banner__icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 1.3rem; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.dav-banner__text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.dav-pill { + align-self: flex-start; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + background: rgb(16 185 129 / 25%); + color: #a7f3d0; + border: 1px solid rgb(167 243 208 / 40%); +} + +.dav-pill--closed { + background: rgb(148 163 184 / 25%); + color: #e2e8f0; + border-color: rgb(226 232 240 / 40%); +} + +.dav-banner__title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; +} + +.dav-banner__meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; +} + +.dav-banner__label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(255 255 255 / 60%); +} + +.dav-banner__value { + font-size: 0.9rem; + font-weight: 600; +} + +.dav-banner__boxes { + margin-left: auto; + display: flex; + gap: 12px; +} + +.dav-box { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 18px; + border-radius: 12px; + background: rgb(255 255 255 / 12%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.dav-box__label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(255 255 255 / 70%); +} + +.dav-box__value { + font-size: 1.15rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dav-banner__actions { + display: flex; + gap: 8px; +} + +.dav-icon-btn { + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid rgb(255 255 255 / 35%); + background: rgb(255 255 255 / 12%); + color: #fff; + cursor: pointer; + + &:hover { + background: rgb(255 255 255 / 22%); + } +} + +/* ── Tabs ─────────────────────────────────────────────────────────────── */ + +.dav-tabs { + display: flex; + gap: 8px; + padding: 8px; + border-radius: 14px; + background: #fff; + border: 1px solid rgb(0 0 0 / 7%); +} + +.dav-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border: 0; + border-radius: 10px; + background: transparent; + font-size: 0.95rem; + font-weight: 600; + color: #475569; + cursor: pointer; + + &:hover { + background: #f1f5f9; + } +} + +.dav-tab--on { + color: #fff; + background: linear-gradient(90deg, #1d4ed8, #2563eb); + box-shadow: 0 4px 12px rgb(37 99 235 / 30%); + + &:hover { + background: linear-gradient(90deg, #1d4ed8, #2563eb); + } +} + +/* ── Cards ────────────────────────────────────────────────────────────── */ + +.dav-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: 16px; + align-items: start; +} + +.dav-column { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dav-card { + border-radius: 14px; + background: #fff; + border: 1px solid rgb(0 0 0 / 7%); + overflow: hidden; +} + +.dav-card__head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + font-size: 1rem; + font-weight: 700; + background: #f8fafc; + border-bottom: 1px solid rgb(0 0 0 / 7%); +} + +.dav-card__dot { + width: 30px; + height: 30px; + border-radius: 8px; + background: linear-gradient(135deg, #2563eb, #1e40af); +} + +.dav-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px dashed rgb(0 0 0 / 7%); + font-size: 0.92rem; + + &:last-child { + border-bottom: 0; + } + + span { + color: #334155; + } + + strong { + text-align: right; + font-weight: 600; + } +} + +.dav-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 48px 16px; + color: rgb(0 0 0 / 50%); +} + +/* ── Transactions ─────────────────────────────────────────────────────── */ + +.dav-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 20px; + background: #f8fafc; + border-bottom: 1px solid rgb(0 0 0 / 7%); +} + +.dav-datepick { + display: flex; + flex-direction: column; + gap: 2px; + + span { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(0 0 0 / 45%); + } + + input { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgb(0 0 0 / 15%); + font: inherit; + } +} + +.dav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid rgb(0 0 0 / 15%); + background: #fff; + font-weight: 600; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.dav-btn--primary { + color: #fff; + border-color: transparent; + background: linear-gradient(90deg, #1d4ed8, #2563eb); +} + +.dav-totals { + margin-left: auto; + display: flex; + gap: 16px; + font-size: 0.88rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dav-table-wrap { + overflow-x: auto; +} + +.dav-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; + + th { + padding: 14px 16px; + text-align: left; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #475569; + border-bottom: 1px solid rgb(0 0 0 / 8%); + white-space: nowrap; + } + + td { + padding: 16px; + border-bottom: 1px solid rgb(0 0 0 / 5%); + font-variant-numeric: tabular-nums; + } + + tbody tr:nth-child(odd) { + background: #f8fafc; + } +} + +.dav-desc { + max-width: 320px; + color: rgb(0 0 0 / 65%); +} + +@media (width <= 720px) { + .dav-page { + padding: 16px; + } + + .dav-banner__boxes { + margin-left: 0; + width: 100%; + } + + .dav-tab { + font-size: 0.85rem; + padding: 8px; + } +} + +/* ── Passbook ─────────────────────────────────────────────────────────── */ + +.dav-push { + margin-left: auto; +} + +.dav-passbook { + padding: 20px; +} + +.dav-pb-head { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px 32px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 2px solid #1e3a8a; +} + +.dav-pb-title { + font-size: 1.2rem; + font-weight: 700; + color: #1e3a8a; +} + +.dav-pb-sub { + font-size: 0.85rem; + color: rgb(0 0 0 / 55%); +} + +.dav-pb-grid { + display: grid; + grid-template-columns: auto auto; + gap: 4px 16px; + font-size: 0.88rem; + + span { + color: rgb(0 0 0 / 55%); + } +} + +.dav-pb-table { + tfoot td { + padding: 14px 16px; + border-top: 2px solid rgb(0 0 0 / 15%); + } +} + +.dav-pb-note { + display: block; + font-size: 0.78rem; + color: rgb(0 0 0 / 50%); +} + +/* Closure tab */ +.dav-closure-tag { + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: #fff3e0; + color: #e65100; + font-size: 0.75rem; + font-style: normal; +} + +.dav-closure-total { + border-top: 1px solid rgb(0 0 0 / 10%); + font-weight: 600; +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts new file mode 100644 index 000000000..33c3c817b --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts @@ -0,0 +1,297 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; +import { OrganizationService } from 'app/organization/organization.service'; + +/** What each DDS transaction type is called on screen. */ +const TRANSACTION_LABELS: Record = { + PARTY_TRAN: 'Collection', + INTEREST_POST: 'Interest', + OD_INTEREST_POST: 'OD Interest', + MATURITY_PAYOUT: 'Maturity Payout', + PREMATURE_PAYOUT: 'Premature Payout', + PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)', + PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent', + WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)', + TRANSFER: 'Transfer' +}; + +/** + * One DDS account and everything that has moved on it, as the savings account page shows a savings + * account: the account's terms and where it stands, then its transactions with the balance after + * each, narrowed to a day or a range. + * + * Read-only. The account is changed from DDS Party Master; its money moves through collections, the + * cashier and the batch runs. + */ +@Component({ + selector: 'mifosx-dds-account-view', + templateUrl: './dds-account-view.component.html', + styleUrls: ['./dds-account-view.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + ReactiveFormsModule, + NgClass, + FaIconComponent, + MatTooltipModule, + MatProgressSpinnerModule, + FormatNumberPipe + ] +}) +export class DdsAccountViewComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private organizationService = inject(OrganizationService); + + isLoading = true; + account: any = null; + private transactions: any[] = []; + + /** The page's tabs, as the savings account page has them - only those that apply to DDS. */ + tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general'; + + /** How a closed account was closed - read only for closed accounts, for the Closure tab. */ + closure: any = null; + + /** What each kind of closure is called on screen. */ + readonly closureLabels: Record = { + PREMATURE: 'Premature Close', + MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close', + MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed', + MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)', + CLOSED_NIL_BALANCE: 'Closed with nil balance' + }; + + /** The passbook reads oldest first, as it is written up. */ + get passbookEntries(): any[] { + return [...this.shownTransactions].reverse(); + } + + /** The balance after the last entry shown. */ + get passbookClosingBalance(): number { + const entries = this.passbookEntries; + return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0; + } + + /** + * Prints the passbook alone. It is copied into a window of its own with only its own styles, so the + * page's menus, banner and tabs do not come out on the paper. + */ + printPassbook(): void { + const passbook = document.getElementById('dds-passbook'); + const popup = window.open('', '_blank', 'width=900,height=700'); + if (!passbook || !popup) { + return; + } + popup.document.write(`${this.account?.accountNo || 'DDS'} Passbook${passbook.innerHTML}`); + popup.document.close(); + popup.focus(); + popup.print(); + } + + fromDateControl = new FormControl(''); + toDateControl = new FormControl(''); + + readonly today = new Date().toISOString().split('T')[0]; + + ngOnInit(): void { + const partyId = this.route.snapshot.paramMap.get('partyId'); + if (!partyId) { + this.isLoading = false; + return; + } + forkJoin({ + account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))), + transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([]))) + }).subscribe(({ account, transactions }) => { + this.account = account; + if (account?.status === 'CLOSED') { + this.organizationService.getPartyClosure(partyId).subscribe({ + next: (closure: any) => (this.closure = closure), + error: () => (this.closure = null) + }); + } + // Newest first, and within a day the later entry first - the order a passbook is read back in. + this.transactions = (Array.isArray(transactions) ? transactions : []).sort( + (a: any, b: any) => + String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id) + ); + this.isLoading = false; + }); + } + + /** The transactions in the chosen range - all of them when no range is set. */ + get shownTransactions(): any[] { + const from = this.fromDateControl.value; + const to = this.toDateControl.value; + return this.transactions.filter((txn: any) => { + const day = String(txn.transactionDate || ''); + if (from && day < String(from)) { + return false; + } + if (to && day > String(to)) { + return false; + } + return true; + }); + } + + get totalCredits(): number { + return this.sumOf('CREDIT'); + } + + get totalDebits(): number { + return this.sumOf('DEBIT'); + } + + private sumOf(direction: string): number { + return this.shownTransactions + .filter((txn: any) => txn.direction === direction) + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + /** Everything of one type ever credited, whatever the date filter says - for the summary cards. */ + totalOf(type: string): number { + return this.transactions + .filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + /** Interest credited, less any taken back on an early closure. */ + get interestEarned(): number { + const recovered = this.transactions + .filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + return ( + this.totalOf('INTEREST_POST') + + this.totalOf('OD_INTEREST_POST') + + this.totalOf('PREMATURE_AGENT_TOP_UP') - + recovered + ); + } + + /** Money that has left the account - payouts, withdrawals, transfers out. */ + get totalDebitsAll(): number { + return this.transactions + .filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + get lastTransactionDate(): string | null { + return this.transactions.length > 0 ? this.transactions[0].transactionDate : null; + } + + /** The rows on screen, as a spreadsheet the branch can keep or send on. */ + exportCsv(): void { + const header = [ + '#', + 'ID', + 'Transaction Date', + 'Transaction Type', + 'Voucher', + 'Description', + 'Account', + 'Debit', + 'Credit', + 'Balance' + ]; + const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const lines = this.shownTransactions.map((txn: any, index: number) => + [ + index + 1, + txn.id, + txn.transactionDate, + this.label(txn.transactionType), + txn.voucherNo, + txn.description, + txn.accountNo, + txn.direction === 'DEBIT' ? txn.amount : '', + txn.direction === 'CREDIT' ? txn.amount : '', + txn.balanceAfterTransaction + ] + .map(quote) + .join(',') + ); + const blob = new Blob( + [[ + header.map(quote).join(','), + ...lines + ].join('\r\n')], + { type: 'text/csv;charset=utf-8' } + ); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`; + link.click(); + URL.revokeObjectURL(link.href); + } + + get hasDateFilters(): boolean { + return !!(this.fromDateControl.value || this.toDateControl.value); + } + + showToday(): void { + this.fromDateControl.setValue(this.today); + this.toDateControl.setValue(this.today); + } + + clearDateFilters(): void { + this.fromDateControl.setValue(''); + this.toDateControl.setValue(''); + } + + label(type: string): string { + return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' '); + } + + statusClass(): string { + switch (this.account?.status) { + case 'ACTIVE': + return 'status--active'; + case 'CLOSED': + return 'status--closed'; + default: + return 'status--default'; + } + } + + back(): void { + this.router.navigate(['/accounts/dds-accounts']); + } + + openInPartyMaster(): void { + this.router.navigate([ + '/accounts/dds', + this.account.id, + 'edit' + ]); + } +} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.html b/src/app/deposits/dds-accounts/dds-accounts.component.html new file mode 100644 index 000000000..e1edbc9c7 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.html @@ -0,0 +1,211 @@ + + +
+ +
+
+ +
+
+

DDS Accounts

+ + @if (isLoading) { + Loading... + } @else { + {{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed + } + +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+
+
+ Opened From + +
+
+ +
+
+ To + +
+
+ +
+ +
+ + + As On Date + + +
+ + @if (hasDateFilters) { + + } +
+ + +
+ @if (isLoading) { +
+ + Loading accounts… +
+ } + + @if (!isLoading && rows.length === 0) { +
+
+ +
+

No DDS accounts found

+

Try adjusting your search

+
+ } + + @if (!isLoading && rows.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No + {{ row.accountNo }} + Type + DDS + Client +
+
{{ (row.clientName || row.partyName || '?')[0] }}
+ {{ row.clientName || row.partyName }} +
+
Product + {{ row.subType }} + Balance + ₹ {{ row.currentBalance || 0 | formatNumber }} + Status + {{ row.status }} + Actions + +
+
+ + + } +
+
diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.scss b/src/app/deposits/dds-accounts/dds-accounts.component.scss new file mode 100644 index 000000000..80e7d5189 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.scss @@ -0,0 +1,56 @@ +/** + * 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/. + */ + +/* All / Active / Closed - which DDS accounts the list shows. */ +.dds-status-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px; + + &__btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 999px; + background: #fff; + color: rgb(0 0 0 / 70%); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + + &.is-on { + border-color: #1565c0; + background: #e3f2fd; + color: #0d47a1; + } + + &--active.is-on { + border-color: #2e7d32; + background: #e8f5e9; + color: #1b5e20; + } + + &--closed.is-on { + border-color: #c62828; + background: #ffebee; + color: #b71c1c; + } + } + + &__count { + min-width: 22px; + padding: 0 6px; + border-radius: 999px; + background: rgb(0 0 0 / 8%); + font-size: 0.75rem; + text-align: center; + } +} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.ts b/src/app/deposits/dds-accounts/dds-accounts.component.ts new file mode 100644 index 000000000..811e5e9a3 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.ts @@ -0,0 +1,265 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, + MatHeaderRowDef, + MatRow, + MatRowDef, + MatTable +} from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product, + * balance and status, searched by account number or client and narrowed by opening date. Each opens + * onto the account's own page and its transactions. + * + * Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections, + * the cashier and the batch runs. + */ +@Component({ + selector: 'mifosx-dds-accounts', + templateUrl: './dds-accounts.component.html', + // The Savings Accounts list's own look, so the two screens read as one family. + styleUrls: [ + '../../accounts/accounts-list/accounts-list.component.scss', + './dds-accounts.component.scss' + ], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + ReactiveFormsModule, + NgClass, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltipModule, + MatProgressSpinnerModule, + FormatNumberPipe + ] +}) +export class DdsAccountsComponent implements OnInit, OnDestroy { + private organizationService = inject(OrganizationService); + private router = inject(Router); + private destroy$ = new Subject(); + + displayedColumns = [ + 'accountNo', + 'depositType', + 'clientName', + 'productName', + 'balance', + 'status', + 'actions' + ]; + + /** Every DDS account, fetched once; search, dates and paging work on this. */ + private allAccounts: any[] = []; + /** The accounts matching the search and dates, whatever their status - what the status counts read. */ + private beforeStatus: any[] = []; + /** The accounts matching the search, dates and status, before paging. */ + private matching: any[] = []; + + /** Which accounts are shown: all of them, only the open ones, or only the closed. */ + statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL'; + /** The page on screen. */ + rows: any[] = []; + + pageSize = 20; + pageIndex = 0; + isLoading = false; + + searchControl = new FormControl(''); + searchType: 'accountNo' | 'clientName' = 'accountNo'; + fromDateControl = new FormControl(''); + toDateControl = new FormControl(''); + asOnDateControl = new FormControl(''); + + readonly today = new Date().toISOString().split('T')[0]; + + get totalRecords(): number { + return this.matching.length; + } + + get activeCount(): number { + return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length; + } + + get closedCount(): number { + return this.beforeStatus.filter((account) => account.status === 'CLOSED').length; + } + + get allCount(): number { + return this.beforeStatus.length; + } + + setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void { + this.statusFilter = status; + this.pageIndex = 0; + this.applyFilters(); + } + + get hasDateFilters(): boolean { + return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value); + } + + ngOnInit(): void { + this.loadAccounts(); + + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(() => { + this.pageIndex = 0; + this.applyFilters(); + }); + merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges) + .pipe(debounceTime(300), takeUntil(this.destroy$)) + .subscribe(() => { + this.pageIndex = 0; + this.applyFilters(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadAccounts(): void { + this.isLoading = true; + this.organizationService + .getParties() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (parties: any) => { + // Newest first, as the savings list shows them. + this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort( + (a: any, b: any) => Number(b.id) - Number(a.id) + ); + this.isLoading = false; + this.applyFilters(); + }, + error: () => { + this.allAccounts = []; + this.isLoading = false; + this.applyFilters(); + } + }); + } + + /** Search by account number or client, and by the day the account was opened. */ + private applyFilters(): void { + const term = String(this.searchControl.value || '') + .trim() + .toLowerCase(); + const fromVal = this.fromDateControl.value; + const toVal = this.toDateControl.value; + const bothDates = !!(fromVal && toVal); + const asOn = this.asOnDateControl.value; + + this.beforeStatus = this.allAccounts.filter((account: any) => { + if (term) { + const haystack = + this.searchType === 'accountNo' + ? `${account.accountNo || ''} ${account.partyCode || ''}` + : `${account.clientName || ''} ${account.partyName || ''}`; + if (!haystack.toLowerCase().includes(term)) { + return false; + } + } + const opened = String(account.openingDate || ''); + if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) { + return false; + } + // As on a date: the accounts that had been opened by then. + if (asOn && opened && opened > String(asOn)) { + return false; + } + return true; + }); + this.matching = + this.statusFilter === 'ALL' + ? this.beforeStatus + : this.beforeStatus.filter((account: any) => account.status === this.statusFilter); + this.showPage(); + } + + private showPage(): void { + const start = this.pageIndex * this.pageSize; + this.rows = this.matching.slice(start, start + this.pageSize); + } + + onPageChange(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.showPage(); + } + + toggleSearchType(): void { + this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo'; + this.pageIndex = 0; + this.applyFilters(); + } + + clearSearch(): void { + this.searchControl.setValue(''); + } + + clearDateFilters(): void { + this.fromDateControl.setValue('', { emitEvent: false }); + this.toDateControl.setValue('', { emitEvent: false }); + this.asOnDateControl.setValue('', { emitEvent: false }); + this.pageIndex = 0; + this.applyFilters(); + } + + viewAccount(account: any): void { + this.router.navigate([ + '/accounts/dds-accounts', + account.id + ]); + } + + getStatusClass(account: any): string { + switch (account?.status) { + case 'ACTIVE': + return 'status--active'; + case 'CLOSED': + return 'status--closed'; + default: + return 'status--default'; + } + } +} diff --git a/src/app/deposits/dds-approvals/dds-approval-notice.ts b/src/app/deposits/dds-approvals/dds-approval-notice.ts new file mode 100644 index 000000000..34caac788 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-notice.ts @@ -0,0 +1,46 @@ +/** + * 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 { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; + +/** + * Whether the server held a DDS entry for a checker rather than carrying it out. The reply then + * names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own. + */ +export function heldForApproval(response: any): boolean { + return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null); +} + +/** + * Tells the clerk their entry went to a checker - with the voucher it will move under - instead of + * saying it was done. Nothing has moved yet, and a message that read as though it had would send them + * looking for money that is still waiting on a manager. + */ +export function announceHeldForApproval( + response: any, + what: string, + organizationService: OrganizationService, + alertService: AlertService +): void { + const say = (voucherNo?: string) => + alertService.alert({ + type: 'Sent for Approval', + message: + `${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` + + 'It takes effect once a checker approves it in Approval Center › DDS Approvals.' + }); + if (!response?.commandId) { + say(); + return; + } + organizationService.getDdsApproval(response.commandId).subscribe({ + next: (entry: any) => say(entry?.voucherNo), + error: () => say() + }); +} diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html new file mode 100644 index 000000000..9a1b3412e --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html @@ -0,0 +1,265 @@ + + + +
+ + + @if (loading) { +
+ hourglass_empty + Loading entry… +
+ } @else { +
+ +
+
+
+ {{ voucherNo || '—' }} + + {{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }} + +
+
+ + person + {{ entry.makerName || entry.maker }} ({{ entry.maker }}) + + + schedule + {{ formatDateTime(entry.madeOn) }} + + @if (entry.status !== 'PENDING' && entry.checker) { + + check_circle + Actioned by: {{ entry.checkerName || entry.checker }} + + } +
+
+
+ + +
+
+ swap_horiz + {{ entry.type }} Summary +
+
+
+ Total Amount + + {{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }} + +
+
+ DR Accounts + {{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} +
+
+ CR Accounts + {{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} +
+
+ Details + {{ entry.details || '—' }} +
+ @if (detail?.description) { +
+ Description + {{ detail.description }} +
+ } +
+
+ + + @if (debitLines.length) { +
+
+ arrow_upward + Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out) +
+ + + + + + + + + + + + @for (line of debitLines; track $index; let i = $index) { + + + + + + + + } + + + + + +
#Account NoAccount NameTypeAmount (DR)
{{ i + 1 }}{{ line.accountNo || '—' }}{{ line.accountName || '—' }} + {{ line.type || '—' }} + ₹{{ line.amount | number: '1.2-2' }}
Total Debit₹{{ total(debitLines) | number: '1.2-2' }}
+
+ } + + + @if (creditLines.length) { +
+
+ arrow_downward + Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in) +
+ + + + + + + + + + + + @for (line of creditLines; track $index; let i = $index) { + + + + + + + + } + + + + + +
#Account NoAccount NameTypeAmount (CR)
{{ i + 1 }}{{ line.accountNo || '—' }}{{ line.accountName || '—' }} + {{ line.type || '—' }} + ₹{{ line.amount | number: '1.2-2' }}
Total Credit₹{{ total(creditLines) | number: '1.2-2' }}
+
+ } + + @if (entry.status === 'PENDING' && isMine && !done) { +
+
+ info + Your own entry +
+

+ You made this entry, so another user with checker rights has to approve or reject it. +

+
+ } + + @if (entry.status === 'REJECTED') { +
+
+ cancel + Rejected +
+

+ Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}. +

+ @if (entry.checker) { +
+ manage_accounts + {{ entry.checker }} +
+ } +
+ } + + @if (entry.status === 'APPROVED') { +
+
+ check_circle + Approved and carried out +
+

+ {{ + voucherNo + ? 'Voucher ' + voucherNo + : entry.entity === 'DDSBATCH' + ? 'Posting is running - refresh for its voucher' + : '—' + }} +

+
+ manage_accounts + Approved by: {{ entry.checker || '—' }} +
+
+ } + + @if (error) { +
+
+ error + Could not complete +
+

{{ error }}

+
+ } + + @if (entry.status === 'PENDING' && !done) { +
+ @if (!confirmReject) { + + + } @else { +
+ +
+ + +
+
+ } +
+ } +
+ } +
diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts new file mode 100644 index 000000000..4cc45f2e6 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts @@ -0,0 +1,148 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; + +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on + * top, who made it and when, then the debit and credit entries it moves - and styled by the same + * sheet, so a checker reads both queues the same way. + * + * The checker approves it, which carries it out under the voucher numbered when it was sent, or + * rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who + * made the entry, and the server refuses it as well. + */ +@Component({ + selector: 'mifosx-dds-approval-review', + templateUrl: './dds-approval-review.component.html', + styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatIconModule + ] +}) +export class DdsApprovalReviewComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + entry: any = this.data.entry; + /** The entry in full, with the debit and credit sides it moves. */ + detail: any = null; + loading = true; + working = false; + confirmReject = false; + done = false; + error = ''; + /** Set by an approval, in case the list row did not carry the number yet. */ + approvedVoucher = ''; + + ngOnInit(): void { + this.load(); + } + + private load(): void { + this.organizationService.getDdsApproval(this.entry.id).subscribe({ + next: (detail: any) => { + this.detail = detail; + this.entry = { ...this.entry, ...detail }; + this.loading = false; + }, + error: () => (this.loading = false) + }); + } + + get voucherNo(): string { + return this.approvedVoucher || this.entry?.voucherNo || ''; + } + + get debitLines(): any[] { + return (this.detail?.lines || []).filter((line: any) => line.side === 'DR'); + } + + get creditLines(): any[] { + return (this.detail?.lines || []).filter((line: any) => line.side === 'CR'); + } + + total(lines: any[]): number { + return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0); + } + + get isMine(): boolean { + return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId); + } + + get canAct(): boolean { + return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working; + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'dri-status--pending', + APPROVED: 'dri-status--approved', + REJECTED: 'dri-status--rejected' + }; + return map[status] ?? ''; + } + + act(command: 'approve' | 'reject'): void { + this.working = true; + this.error = ''; + this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({ + next: (result: any) => { + this.working = false; + this.done = true; + this.approvedVoucher = result?.voucherNo || ''; + this.entry = { + ...this.entry, + status: command === 'approve' ? 'APPROVED' : 'REJECTED', + voucherCancelled: command === 'reject' && !!this.voucherNo + }; + // The approved entry's sides are now the voucher's own lines. + this.load(); + }, + error: (err: any) => { + this.working = false; + const body = err?.error; + this.error = + body?.errors?.[0]?.defaultUserMessage || + body?.defaultUserMessage || + body?.developerMessage || + 'Could not complete this.'; + } + }); + } + + /** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */ + formatDateTime(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(String(val).replace(' ', 'T')); + return isNaN(d.getTime()) + ? String(val) + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + close(): void { + this.dialogRef.close(this.done); + } +} diff --git a/src/app/deposits/dds-approvals/dds-approvals.component.html b/src/app/deposits/dds-approvals/dds-approvals.component.html new file mode 100644 index 000000000..b8e4016c7 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approvals.component.html @@ -0,0 +1,176 @@ + + +
+
+
+ savings +
+
DDS Approvals
+
+ Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings. +
+
+
+
+ +
+ + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && entries.length === 0) { +
+ +

No DDS entries

+

+ @if (filterStatus === 'PENDING') { + There are no DDS entries waiting for approval. + } @else { + No {{ filterStatus | lowercase }} DDS entries found. + } +

+
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (e of pagedEntries; track e.id) { + + + + + + + + + + + + + } + +
Voucher NoTypeAccount / DetailsAmountEntriesClerkReviewed BySubmitted AtStatusAction
+ {{ e.voucherNo || '—' }} + @if (e.voucherCancelled) { + (cancelled) + } + + {{ e.type }} + + {{ e.details || '—' }} + + {{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }} + + @if (e.entries) { + {{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }} + } @else { + — + } + + {{ e.makerName || e.maker || '—' }} + @if (isMine(e) && e.status === 'PENDING') { + (you) + } + {{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}{{ formatDateTime(e.madeOn) }} + {{ e.status | titlecase }} + + +
+
+ + + } +
diff --git a/src/app/deposits/dds-approvals/dds-approvals.component.ts b/src/app/deposits/dds-approvals/dds-approvals.component.ts new file mode 100644 index 000000000..1c699ec1c --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approvals.component.ts @@ -0,0 +1,215 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component'; + +/** + * Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what + * it does, on which accounts, for how much - and, once approved, the voucher it moved under. + * + * Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the + * same way. The entries are the platform's own maker-checker entries; approving one here carries it out. + */ +@Component({ + selector: 'mifosx-dds-approvals', + templateUrl: './dds-approvals.component.html', + styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatPaginatorModule, + MatNativeDateModule, + MatDatepickerModule + ] +}) +export class DdsApprovalsComponent implements OnInit { + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private authenticationService = inject(AuthenticationService); + private dialog = inject(MatDialog); + + entries: any[] = []; + pageSize = 20; + pageIndex = 0; + readonly pageSizeOptions = [ + 10, + 20, + 50 + ]; + + /** The page of entries on screen. */ + get pagedEntries(): any[] { + const start = this.pageIndex * this.pageSize; + return this.entries.slice(start, start + this.pageSize); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + } + filterStatus = 'PENDING'; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset = 'today'; + loading = false; + loaded = false; + + readonly currentUserId = this.authenticationService.getCredentials()?.userId; + + readonly statusOptions = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: 'This Week' }, + { key: 'month', label: 'This Month' }, + { key: 'all', label: 'All' } + ]; + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate || new Date()); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.applyPreset('today'); + } + + load(): void { + this.loading = true; + this.organizationService + .getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate)) + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + this.entries = Array.isArray(rows) ? rows : []; + this.pageIndex = 0; + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate || new Date()); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'week') { + const start = new Date(today); + start.setDate(today.getDate() - today.getDay()); + this.fromDate = start; + this.toDate = new Date(today); + } else if (key === 'month') { + this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + this.toDate = new Date(today); + } else { + this.fromDate = null; + this.toDate = null; + } + this.load(); + } + + review(entry: any): void { + this.dialog + .open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false }) + .afterClosed() + // However the dialog was closed - its Close button, a click outside, Esc - the list is read + // again, so an entry just approved or rejected leaves the Pending list at once. + .subscribe(() => this.load()); + } + + /** Whose entry it is - a checker cannot pass their own. */ + isMine(entry: any): boolean { + return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'wri-status--pending', + APPROVED: 'wri-status--approved', + REJECTED: 'wri-status--rejected' + }; + return map[status] ?? ''; + } + + typeColor(entry: any): string { + const map: Record = { + Transfer: '#e3f2fd', + 'Apply Receipt': '#fce4ec' + }; + if (String(entry.type).startsWith('Premature')) { + return '#fff3e0'; + } + if (String(entry.type).startsWith('Maturity')) { + return '#e8f5e9'; + } + if (String(entry.type).startsWith('Post')) { + return '#ede7f6'; + } + return map[entry.type] ?? '#f5f5f5'; + } + + formatDateTime(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(String(val).replace(' ', 'T')); + return isNaN(d.getTime()) + ? String(val) + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) { + return undefined; + } + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/deposits/party-master/client-search.ts b/src/app/deposits/party-master/client-search.ts new file mode 100644 index 000000000..a898f8590 --- /dev/null +++ b/src/app/deposits/party-master/client-search.ts @@ -0,0 +1,49 @@ +/** + * 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 { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { ClientsService } from 'app/clients/clients.service'; + +/** + * Finds clients by name whatever case the officer types it in. + * + * The platform's name filter matches anywhere in the name but only in the exact case typed, so + * "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term + * is tried as typed, capitalised word by word, and in upper and lower case, and the results merged. + * Done here rather than by changing the platform's search, which every other screen relies on. + */ +export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable { + const variants = Array.from( + new Set([ + term, + term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()), + term.toUpperCase(), + term.toLowerCase() + ]) + ); + return forkJoin( + variants.map((variant) => + clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe( + map((response: any) => { + const found = response?.pageItems || response || []; + return Array.isArray(found) ? found : []; + }), + catchError(() => of([])) + ) + ) + ).pipe( + map((lists: any[][]) => { + const byId = new Map(); + for (const client of lists.flat()) { + byId.set(client.id, client); + } + return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName))); + }) + ); +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.html b/src/app/deposits/party-master/create-party/create-party.component.html index 66ba08cdd..45f765ff1 100644 --- a/src/app/deposits/party-master/create-party/create-party.component.html +++ b/src/app/deposits/party-master/create-party/create-party.component.html @@ -26,7 +26,9 @@

Customer & Scheme

- Who this account is for, which scheme it runs under, and which agent collects for it + Who this account is for, which scheme it runs under, and which agent collects for it
@@ -42,7 +44,11 @@ autocomplete="off" /> - + @for (client of clientOptions; track client.id) {
@@ -81,7 +87,11 @@ autocomplete="off" /> - + @for (type of subTypeOptions; track type.id) {
@@ -114,7 +124,11 @@ autocomplete="off" /> - + @for (agent of agentOptions; track agent.id) {
@@ -163,20 +177,37 @@
- - @if (savingsAccountNo) { + @if (accountNo) { + +
+ + +
+ } @else if (savingsAccountNo) {
@@ -224,7 +255,7 @@
- +
@@ -234,39 +265,45 @@
- +
- +
- +
- +
- +
- +
@@ -284,35 +321,61 @@
- +
- +
- +
- +
- +
- +
@@ -330,7 +393,12 @@
- + @@ -364,7 +432,9 @@

Minor / Guardian

- Only for a minor's account — the guardian who operates it until they come of age + Only for a minor's account — the guardian who operates it until they come of age
@@ -382,7 +452,13 @@
- + Turns 18 on @@ -396,12 +472,17 @@ matInput required formControlName="guardianClientId" + [readonly]="isFromClient('guardianClientId')" [matAutocomplete]="guardianAuto" placeholder="Type an ID or search by name…" autocomplete="off" /> - + @for (client of guardianOptions; track client.id) {
@@ -428,7 +509,7 @@
- + To the minor
@@ -535,24 +616,8 @@
-
- - - - % - -
- -
- - - - @for (type of withdrawTypes; track type) { - {{ type }} - } - - -
+ +
@@ -610,7 +675,13 @@
- + Write the reason in your own words
@@ -687,17 +758,7 @@
- -
- - - - @for (code of advCodes; track code) { - {{ code }} - } - - -
+
@@ -723,7 +784,7 @@
- DDS Account - @if (p.savingsAccountNo) { + @if (p.accountNo) { +
+ +
{{ p.subTypeDescription || p.subType }}
+
+ } @else if (p.savingsAccountNo) {
{{ p.savingsAccountStatus }}
diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts index d6641447a..7ec5f2c8a 100644 --- a/src/app/deposits/party-master/party-master.component.ts +++ b/src/app/deposits/party-master/party-master.component.ts @@ -117,8 +117,24 @@ export class PartyMasterComponent implements OnInit { parties: any[] = []; filteredCount = 0; - @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - @ViewChild(MatSort, { static: true }) sort: MatSort; + paginator: MatPaginator; + sort: MatSort; + + // The table sits inside an @if, so these only exist once the list has loaded. Read statically they + // were never there, the table was never paged, and the paginator read "0 of 0" over a full list. + @ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) { + this.paginator = paginator; + if (paginator) { + this.dataSource.paginator = paginator; + } + } + + @ViewChild(MatSort) set matSort(sort: MatSort) { + this.sort = sort; + if (sort) { + this.dataSource.sort = sort; + } + } get totalCount(): number { return this.parties.length; @@ -163,7 +179,10 @@ export class PartyMasterComponent implements OnInit { next: (due: any) => { const rows = Array.isArray(due) ? due : due?.pageItems || []; this.dueForClosureCount = rows.length; - this.dueForClosurePayable = rows.reduce((sum: number, party: any) => sum + Number(party.currentBalance || 0), 0); + this.dueForClosurePayable = rows.reduce( + (sum: number, party: any) => sum + Number(party.currentBalance || 0), + 0 + ); }, // The party list is the point of this screen; a failed banner must not take it down with it. error: () => { diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html index 4e724598e..7553845f3 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html @@ -20,11 +20,67 @@ Matured on {{ data.maturityDate | dateFormat }}
-
- Amount payable - {{ data.currentBalance | number: '1.2-2' }} +
+ Opened on + {{ data.openingDate | dateFormat }}
+ + @if (breakdown) { +
+
+ Deposits collected + {{ breakdown.deposits | number: '1.2-2' }} +
+ @if (breakdown.transfersIn > 0) { +
+ Transferred in + + {{ breakdown.transfersIn | number: '1.2-2' }} +
+ } + @if (breakdown.transfersOut > 0) { +
+ Transferred out + − {{ breakdown.transfersOut | number: '1.2-2' }} +
+ } + @if (breakdown.other !== 0) { +
+ Other (opening balance / adjustments) + {{ breakdown.other | number: '1.2-2' }} +
+ } +
+ + Interest @ {{ data.interestRate | number: '1.2-2' }}% + {{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }} + + + {{ breakdown.interest | number: '1.2-2' }} +
+ @if (breakdown.interestToPost > 0) { +
+ Interest calculated, posted with this payout + + {{ breakdown.interestToPost | number: '1.2-2' }} +
+ } +
+ } + +
+ Total payable + {{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }} +
+ @if (interestStillToCome) { +

+ Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet + - it is added when this is paid out, so the amount paid will be a little more. +

+ }
+

+ Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more. +

@if (loading) { @@ -46,28 +102,57 @@ Account @for (account of ownAccounts; track account.id) { - - {{ account.accountNo }} · {{ account.productName }} - + {{ account.accountNo }} · {{ account.productName }} } } - To an account at another bank - - Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers - + Park in Sundry + When the customer has no account here - held in the bank's Sundry account in their name + @if (destination === 'other') { + + } + @if (destination === 'other') {
- The money comes off {{ data.partyCode }} today and sits in the bank's Matured Deposits Payable - account until the transfer is made from Fund Transfers — so it is accounted for the whole - time, and a failed transfer loses nothing. + The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against + {{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost.
} } @@ -75,5 +160,5 @@ - + diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss index 7d58ea20a..7e62e8ee6 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss @@ -38,6 +38,22 @@ font-weight: 500; font-variant-numeric: tabular-nums; } + + &__note { + display: block; + margin: 0; + font-size: 0.75rem; + color: rgb(0 0 0 / 45%); + } +} + +/* How the amount payable is made up - one line per source, adding up to the total below. */ +.po-breakdown { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 6px; + border-top: 1px dashed rgb(0 0 0 / 10%); } .po-question { @@ -90,3 +106,7 @@ font-size: 0.78rem; line-height: 1.5; } + +.po-hint-warn { + color: #c62828; +} diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts index 120900150..0ca81b9c1 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts @@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; @@ -46,6 +47,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe'; MatButtonModule, MatRadioModule, MatSelectModule, + MatAutocompleteModule, MatFormFieldModule, MatInputModule, MatProgressBarModule, @@ -72,9 +74,90 @@ export class PayoutDialogComponent implements OnInit { */ ownAccounts: any[] = []; selectedAccountNo = ''; + + /** Where the balance is parked when the customer has no account here. */ + sundryAccounts: any[] = []; + sundryGlAccountId: number | null = null; + /** What is typed in the GL box - a code or part of a name. */ + sundrySearch = ''; + + /** The accounts matching what is typed, by GL code or name, in any case. */ + get sundryOptions(): any[] { + const typed = this.sundrySearch.trim().toLowerCase(); + if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) { + return this.sundryAccounts; + } + return this.sundryAccounts.filter( + (account: any) => + String(account.glCode).toLowerCase().includes(typed) || + String(account.name || '') + .toLowerCase() + .includes(typed) + ); + } + + get chosenSundry(): any { + return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId); + } + + /** Shows a picked account in the box as its code and name. */ + readonly sundryDisplay = (value: any): string => + value && typeof value === 'object' ? this.sundryLabel(value) : value || ''; + + sundryLabel(account: any): string { + return account ? `${account.glCode} · ${account.name}` : ''; + } + + pickSundry(account: any): void { + this.sundryGlAccountId = account ? account.id : null; + this.sundrySearch = this.sundryLabel(account); + } + + /** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */ + onSundryTyped(value: any): void { + // Picking from the list hands over the account itself rather than text. + if (value && typeof value === 'object') { + this.pickSundry(value); + return; + } + value = String(value ?? ''); + this.sundrySearch = value; + if (value !== this.sundryLabel(this.chosenSundry)) { + const exact = this.sundryAccounts.find( + (account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase() + ); + this.sundryGlAccountId = exact ? exact.id : null; + } + } loading = true; + /** + * How the amount payable was made up - what was deposited, what came in or went out by transfer, + * and the interest - read from the account's own transactions so the officer can explain the + * figure to the customer line by line. Null until the transactions have arrived. + */ + breakdown: { + deposits: number; + transfersIn: number; + transfersOut: number; + interest: number; + other: number; + interestToPost: number; + total: number; + } | null = null; + ngOnInit(): void { + // The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account, + // and the clerk searches for it and picks it. + this.organizationService.getDdsSundryAccounts().subscribe({ + next: (accounts: any) => (this.sundryAccounts = accounts || []), + error: () => (this.sundryAccounts = []) + }); + this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({ + next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])), + error: () => (this.breakdown = null) + }); + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ next: (accounts: any) => { this.ownAccounts = accounts || []; @@ -96,14 +179,52 @@ export class PayoutDialogComponent implements OnInit { }); } + /** Interest is still to be worked out for part of the term, and the payout will add it. */ + get interestStillToCome(): boolean { + return ( + !!this.data.interestDate && + !!this.data.maturityDate && + String(this.data.interestDate) < String(this.data.maturityDate) + ); + } + + private breakdownOf(transactions: any[]) { + const sum = (test: (txn: any) => boolean) => + transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0); + const signed = (type: string) => + sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') - + sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT'); + const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER'; + + const deposits = signed('PARTY_TRAN'); + const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT'); + const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT'); + const interest = + signed('INTEREST_POST') + + signed('OD_INTEREST_POST') + + signed('PREMATURE_AGENT_TOP_UP') + + signed('PREMATURE_INT_RECOVERY'); + const balance = Number(this.data.currentBalance || 0); + // Anything the four lines above do not cover - an opening balance brought over, say - so the lines + // still add up to the balance rather than leaving the officer a gap to explain. + const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100; + // Interest already worked out but not yet credited is posted by the payout itself, first. + const interestToPost = Number(this.data.interestCalc || 0); + return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost }; + } + get canPayOut(): boolean { if (this.loading) { return false; } - return this.destination === 'other' || !!this.selectedAccountNo; + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; } payOut(): void { - this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {}); + this.dialogRef.close( + this.destination === 'own' + ? { toSavingsAccountNo: this.selectedAccountNo } + : { sundryGlAccountId: this.sundryGlAccountId } + ); } } diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html index fea269d3b..7fc420782 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html @@ -47,8 +47,8 @@
Interest recalculated at {{ reducedRate | number: '1.0-2' }}% - instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken - back before the balance is paid out. + instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back + before the balance is paid out.
} @else {
Their interest is recalculated at the scheme's reduced rate.
@@ -61,8 +61,8 @@
Agent — {{ data.agentCode }}
- The customer keeps a full month's interest, and the agent funds the days left in this month. The - balance is paid out in full. + The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is + paid out in full.
@@ -86,7 +86,7 @@ Into another account of theirs at this bank - Park it for transfer to another bank + Park in Sundry (no account here) @if (destination === 'own' && ownAccounts.length > 0) { @@ -100,19 +100,76 @@ } + @if (destination === 'other') { + + Park in GL account + + + @for (account of sundryOptions; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + @if (sundryOptions.length === 0) { + No liability GL matches + } + + @if (!sundryGlAccountId) { + Search and pick your bank's Sundry GL account + } @else { + The balance is held here in the customer's name until it is paid to them + } + + } + @if (ownAccounts.length === 0 && !loading) {

- This customer has no other account here, so the balance is parked in matured-deposits payable for the - branch to send on. + This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their + name until it is paid to them.

} -

This closes the account. It cannot be reopened.

+ + @if (withinLockin) { +
+

Within lock-in until {{ closableFrom | dateFormat }}

+

+ {{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for + {{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it. +

+ + Reason for closing within lock-in + + {{ lockinReason.length }}/250 + +
+ } + +

+ @if (withinLockin) { + Once approved, this closes the account. It cannot be reopened. + } @else { + This closes the account. It cannot be reopened. + } +

diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss index a20cc2ccb..7d88e158a 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss @@ -110,3 +110,23 @@ font-size: 0.8rem; font-weight: 500; } + +/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */ +.pcd-lockin { + margin-top: 18px; + padding: 12px 14px 4px; + border-radius: 8px; + border: 1px solid rgb(180 83 9 / 30%); + background: rgb(245 158 11 / 8%); +} + +.pcd-lockin-title { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: #b45309; +} + +.po-hint-warn { + color: #c62828; +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts index 0cc913c5d..53db816e0 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts @@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; @@ -51,6 +52,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe'; MatButtonModule, MatRadioModule, MatSelectModule, + MatAutocompleteModule, MatFormFieldModule, MatInputModule, MatProgressBarModule, @@ -73,6 +75,61 @@ export class PrematureCloseDialogComponent implements OnInit { ownAccounts: any[] = []; selectedAccountNo = ''; + + /** Where the balance is parked when the customer has no account here. */ + sundryAccounts: any[] = []; + sundryGlAccountId: number | null = null; + /** What is typed in the GL box - a code or part of a name. */ + sundrySearch = ''; + + /** The accounts matching what is typed, by GL code or name, in any case. */ + get sundryOptions(): any[] { + const typed = this.sundrySearch.trim().toLowerCase(); + if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) { + return this.sundryAccounts; + } + return this.sundryAccounts.filter( + (account: any) => + String(account.glCode).toLowerCase().includes(typed) || + String(account.name || '') + .toLowerCase() + .includes(typed) + ); + } + + get chosenSundry(): any { + return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId); + } + + /** Shows a picked account in the box as its code and name. */ + readonly sundryDisplay = (value: any): string => + value && typeof value === 'object' ? this.sundryLabel(value) : value || ''; + + sundryLabel(account: any): string { + return account ? `${account.glCode} · ${account.name}` : ''; + } + + pickSundry(account: any): void { + this.sundryGlAccountId = account ? account.id : null; + this.sundrySearch = this.sundryLabel(account); + } + + /** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */ + onSundryTyped(value: any): void { + // Picking from the list hands over the account itself rather than text. + if (value && typeof value === 'object') { + this.pickSundry(value); + return; + } + value = String(value ?? ''); + this.sundrySearch = value; + if (value !== this.sundryLabel(this.chosenSundry)) { + const exact = this.sundryAccounts.find( + (account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase() + ); + this.sundryGlAccountId = exact ? exact.id : null; + } + } loading = true; /** @@ -84,6 +141,12 @@ export class PrematureCloseDialogComponent implements OnInit { scheme: any = null; ngOnInit(): void { + // The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account, + // and the clerk searches for it and picks it. + this.organizationService.getDdsSundryAccounts().subscribe({ + next: (accounts: any) => (this.sundryAccounts = accounts || []), + error: () => (this.sundryAccounts = []) + }); this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ next: (accounts: any) => { this.ownAccounts = accounts || []; @@ -120,17 +183,46 @@ export class PrematureCloseDialogComponent implements OnInit { return Math.max(0, rate - reduction); } + /** Why the lock-in should be waived for this account - required when it is inside it. */ + lockinReason = ''; + + /** + * The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it + * the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason, + * for a manager to approve - the scheme's lock-in itself is never changed to let one account through. + */ + get closableFrom(): Date | null { + const months = Number(this.scheme?.prematureLockinMonths || 0); + const opened = this.data.openingDate ? new Date(this.data.openingDate) : null; + if (months <= 0 || !opened || isNaN(opened.getTime())) { + return null; + } + const closable = new Date(opened); + closable.setMonth(closable.getMonth() + months); + return closable; + } + + get withinLockin(): boolean { + const closable = this.closableFrom; + return !!closable && closable.getTime() > Date.now(); + } + get canClose(): boolean { if (this.loading || !this.borneBy) { return false; } - return this.destination === 'other' || !!this.selectedAccountNo; + if (this.withinLockin && !this.lockinReason.trim()) { + return false; + } + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; } close(): void { this.dialogRef.close({ shortfallBorneBy: this.borneBy, - ...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}) + ...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}), + ...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}), + ...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {}) }); } } diff --git a/src/app/organization/agent-master/agent-master.component.html b/src/app/organization/agent-master/agent-master.component.html index f5733e41e..06483cd18 100644 --- a/src/app/organization/agent-master/agent-master.component.html +++ b/src/app/organization/agent-master/agent-master.component.html @@ -43,7 +43,10 @@
Active Agents
-
+
{{ totalCurrentBalance | number: '1.2-2' }}
@@ -92,6 +95,18 @@ + + + GL Account + + @if (a.glAccountCode) { + {{ a.glAccountCode }} · {{ a.glAccountName }} + } @else { + Not set + } + + + Branch diff --git a/src/app/organization/agent-master/agent-master.component.ts b/src/app/organization/agent-master/agent-master.component.ts index aa6572d10..88d7675b2 100644 --- a/src/app/organization/agent-master/agent-master.component.ts +++ b/src/app/organization/agent-master/agent-master.component.ts @@ -29,6 +29,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { officesUnder } from './offices-under'; /** * Agent Master list. @@ -61,11 +65,13 @@ import { OrganizationService } from 'app/organization/organization.service'; }) export class AgentMasterComponent implements OnInit { private organizationService = inject(OrganizationService); + private authenticationService = inject(AuthenticationService); displayedColumns: string[] = [ 'agentCode', 'clientName', 'homeBranch', + 'glAccount', 'agentCity', 'agentSavingAccountNo', 'agentCurrentBalance', @@ -103,8 +109,21 @@ export class AgentMasterComponent implements OnInit { } private loadAgents(): void { - this.organizationService.getAgents().subscribe((agents: any[]) => { - this.agents = agents || []; + const loginOfficeId = this.authenticationService.getCredentials()?.officeId; + forkJoin({ + agents: this.organizationService.getAgents(), + offices: this.organizationService.getOffices().pipe(catchError(() => of([]))) + }).subscribe(({ agents, offices }: any) => { + // The branch logged in to sees its own agents and those of every branch under it - Head Office, + // at the top, sees them all. + const visible = new Set( + officesUnder(Array.isArray(offices) ? offices : offices?.pageItems || [], loginOfficeId).map( + (office: any) => office.id + ) + ); + this.agents = (agents || []).filter( + (agent: any) => !loginOfficeId || visible.size === 0 || visible.has(agent.officeId) + ); this.dataSource = new MatTableDataSource(this.agents); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.html b/src/app/organization/agent-master/create-agent/create-agent.component.html index 088295fc1..807a9b050 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.html +++ b/src/app/organization/agent-master/create-agent/create-agent.component.html @@ -28,9 +28,45 @@
- Client ID - - @if (clientLookupFailed) { + Client ID or Name + + + + + @for (client of clientOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } + @if (clientListIsPartial) { + + Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest + + } +
+ @if (agentForm.get('clientId')?.hasError('pattern')) { + Pick a client from the list + } @else if (clientAlreadyAgent) { + This client is already agent {{ clientAlreadyAgent }} + } @else if (clientAgeProblem) { + {{ clientAgeProblem }} + } @else if (clientOtherBranch) { + This client belongs to another branch + } @else if (clientLookupFailed) { No client found with this ID } @else if (loadedClientName) { {{ loadedClientName }} @@ -72,8 +108,7 @@
- @@ -111,7 +146,7 @@ (selectionChange)="onSavingsAccountTypePicked()" > @for (accountType of savingsAccountTypes; track accountType) { - {{ accountType }} + {{ savingsAccountTypeCodes[accountType] || accountType }} } Agent Birth Date - - + + From the client's record
@@ -179,10 +211,37 @@ @for (guarantor of guarantors.controls; track guarantor; let i = $index) {
- Client ID - + Client ID or Name + + + + @for (client of guarantorOptionsFor(guarantor); track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { - An agent cannot be its own guarantor + The agent cannot be their own guarantor + } @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) { + This client is already a guarantor above + } @else if (guarantor.get('clientId')?.hasError('pattern')) { + Pick a client from the list }
@@ -244,7 +303,13 @@ -
diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.scss b/src/app/organization/agent-master/create-agent/create-agent.component.scss index a0247c6dd..e1852abe2 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.scss +++ b/src/app/organization/agent-master/create-agent/create-agent.component.scss @@ -144,3 +144,48 @@ $primary: #0f766e; grid-template-columns: repeat(2, 1fr); } } + +/* One row of the client search list: the id, the name, and the branch - as on the Party form. */ +.am-opt { + display: flex; + align-items: baseline; + gap: 10px; + overflow: hidden; +} + +.am-opt-more { + font-size: 0.75rem; + font-style: italic; + opacity: 0.75; +} + +.am-opt-id { + flex: 0 0 auto; + min-width: 52px; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.78rem; + color: #0f766e; +} + +.am-opt-main { + flex: 1 1 auto; + min-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-opt-sub { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: rgb(0 0 0 / 50%); +} + +.am-search-ic { + margin-right: 8px; + color: rgb(0 0 0 / 40%); +} diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.ts b/src/app/organization/agent-master/create-agent/create-agent.component.ts index 50e9b4844..0847ae6a6 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.ts +++ b/src/app/organization/agent-master/create-agent/create-agent.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, @@ -28,9 +28,14 @@ import { SystemService } from 'app/system/system.service'; import { ClientsService } from 'app/clients/clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; /** Custom Directives */ import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; +import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { searchClientsAnyCase } from 'app/deposits/party-master/client-search'; +import { officesUnder } from '../offices-under'; /** Writes a value into a form group only when there is something to write. */ function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void { @@ -61,7 +66,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any) imports: [ ...STANDALONE_SHARED_IMPORTS, // See the Party form: an unimported directive is ignored without a word. - RestrictInputDirective + RestrictInputDirective, + MatAutocompleteModule ] }) export class CreateAgentComponent implements OnInit { @@ -74,21 +80,69 @@ export class CreateAgentComponent implements OnInit { private systemService = inject(SystemService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); + private alertService = inject(AlertService); + private authenticationService = inject(AuthenticationService); + private changeDetector = inject(ChangeDetectorRef); + + /** The agent's client field and its list, so a list that arrives late can still be opened. */ + @ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger; + @ViewChild('clientInput') clientInput?: ElementRef; /** - * The latest date of birth the picker will offer: eighteen years ago today. - * - * It was previously today, which let a clerk save an agent born on the day they were entered — - * the picker opens on today, so a stray Enter was enough. The figure matches the rule the server - * applies, so the calendar cannot offer a date the save would then refuse. + * The branch the officer is logged in to. An agent is opened there and nowhere else: the Home + * Branch list offers only it, and only its clients can be picked. */ - maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate()); + private readonly loginOfficeId: number | null = this.authenticationService.getCredentials()?.officeId ?? null; + + /** + * The branch the agent is being opened for - the one picked in Home Branch, the login branch until + * then. Its clients are the ones offered, since an agent collects for the branch they belong to. + */ + private get branchOfficeId(): number | null { + return Number(this.agentForm?.get('officeId')?.value) || this.loginOfficeId; + } + + /** Set when the client keyed belongs to another branch, which cannot be made an agent here. */ + clientOtherBranch = false; + + /** + * The code of the agent the keyed client already is, if any. One client is one agent, so they are + * left out of the list and refused when typed. + */ + clientAlreadyAgent = ''; + private agentCodeByClientId: Record = {}; + + /** Why the picked client's age rules them out as an agent, or empty when it does not. */ + clientAgeProblem = ''; + /** The ages an agent is taken on at, matching the rule the server applies. */ + private static readonly MIN_AGENT_AGE = 16; + private static readonly MAX_AGENT_AGE = 60; + + /** The agent's client list without the clients who are already agents. */ + private notYetAgents(clients: any[]): any[] { + return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]); + } + agentId: number | null = null; /** Shown under the Client ID field once a client has been resolved. */ loadedClientName = ''; clientLookupFailed = false; + /** + * Clients to pick from, as on the Party form: the first page when the field is focused, then + * whatever matches what has been typed. An officer knows the agent's name, rarely their client id. + */ + clientOptions: any[] = []; + private initialClients: any[] = []; + private static readonly CLIENT_LIST_SIZE = 50; + clientTotal = 0; + showingClientList = false; + + get clientListIsPartial(): boolean { + return this.showingClientList && this.clientTotal > this.clientOptions.length; + } + /** * The client's own open savings accounts, fetched once the client is resolved. The commission * account is picked from these rather than typed, so it is always an account that exists and @@ -103,6 +157,12 @@ export class CreateAgentComponent implements OnInit { */ savingsAccountTypes: string[] = []; + /** + * Each type's product code, which is what the dropdown shows. The type is still saved by its name, + * as before, so what other screens and reports read is unchanged. + */ + savingsAccountTypeCodes: Record = {}; + /** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */ private static readonly SAVINGS_DEPOSIT_TYPE = 100; @@ -141,7 +201,11 @@ export class CreateAgentComponent implements OnInit { agentForm: UntypedFormGroup = this.formBuilder.group({ clientId: [ '', - Validators.required + // A name typed to search with is not a client; only an id picked or keyed saves. + [ + Validators.required, + Validators.pattern(/^\d+$/) + ] ], agentCode: [ { @@ -186,10 +250,8 @@ export class CreateAgentComponent implements OnInit { '', Validators.required ], - agentBirthDate: [ - '', - Validators.required - ], + // Filled from the client, whose date of birth was checked when they were opened. + agentBirthDate: [''], agentCurrentBalance: [ { value: 0, @@ -209,8 +271,39 @@ export class CreateAgentComponent implements OnInit { }); ngOnInit(): void { + // A different branch has different clients: the list is read again, and a client already keyed + // is checked against the new branch. + this.agentForm + .get('officeId') + ?.valueChanges.pipe(distinctUntilChanged()) + .subscribe(() => { + this.initialClients = []; + if (!this.agentId && this.agentForm.get('clientId')?.value) { + this.loadClient(); + } + }); + // Who is already an agent, so they are neither offered nor accepted as a new one. + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.agentCodeByClientId = {}; + for (const agent of agents || []) { + if (agent.active !== false && agent.clientId != null) { + this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode; + } + } + }, + error: () => (this.agentCodeByClientId = {}) + }); + this.organizationService.getOffices().subscribe({ - next: (offices: any) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []), + next: (offices: any) => { + const all = Array.isArray(offices) ? offices : offices?.pageItems || []; + // The branch logged in to and every branch under it - Head Office, at the top, sees them all. + this.offices = officesUnder(all, this.loginOfficeId); + if (!this.agentId && this.offices.some((office: any) => office.id === this.loginOfficeId)) { + this.agentForm.get('officeId')?.setValue(this.loginOfficeId); + } + }, error: () => (this.offices = []) }); @@ -220,7 +313,9 @@ export class CreateAgentComponent implements OnInit { // The client may already have been keyed while this was in flight, so the list is rebuilt // rather than left showing an account this now knows to exclude. const clientId = this.agentForm.get('clientId')?.value; - if (clientId) { this.loadClientSavingsAccounts(String(clientId)); } + if (clientId) { + this.loadClientSavingsAccounts(String(clientId)); + } }, error: () => (this.ddsProductId = null) }); @@ -245,11 +340,24 @@ export class CreateAgentComponent implements OnInit { this.agentForm .get('clientId') ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) - .subscribe(() => { + .subscribe((value) => { + const typed = String(value ?? '').trim(); + // A name is something to search for, not a client yet - the fields stay until one is picked. + if (typed && !/^\d+$/.test(typed)) { + this.searchClients(typed); + return; + } + if (!typed) { + this.showClientList(); + } this.loadClient(); // A guarantor row that was fine may now be the agent itself, or the other way round. for (const row of this.guarantors.controls) { row.get('clientId')?.updateValueAndValidity(); + // And the agent's own client leaves (or rejoins) each row's list. + if (!row.get('clientId')?.value) { + this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients)); + } } }); } @@ -264,7 +372,9 @@ export class CreateAgentComponent implements OnInit { '', [ Validators.required, - this.notTheAgentItself() + Validators.pattern(/^\d+$/), + this.notTheAgentItself(), + this.notAlreadyAGuarantor() ] ], guarantorName: [ @@ -288,14 +398,103 @@ export class CreateAgentComponent implements OnInit { }); // A guarantor is a client too, so its row fills from the client the same way the agent's does. + // Searched by name like the agent's own client field; a picked or keyed id fills the row. group .get('clientId') ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) - .subscribe((clientId) => this.loadGuarantorClient(group, clientId)); + .subscribe((value) => { + const typed = String(value ?? '').trim(); + if (typed && !/^\d+$/.test(typed)) { + searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => { + this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25)); + const panel = (group as any).panel; + this.openWhenFocused(panel?.trigger, panel?.input); + }); + return; + } + this.setGuarantorOptions(group, []); + this.loadGuarantorClient(group, typed); + if (!typed) { + // Emptied: the list comes back rather than leaving nothing to pick from. + this.onGuarantorFocus(group); + } + // Two rows naming the same client: the other row's check has to be run again too. + for (const row of this.guarantors.controls) { + if (row !== group) { + row.get('clientId')?.updateValueAndValidity({ emitEvent: false }); + } + } + }); return group; } + /** + * Each guarantor row's list lives on the row itself, as a plain array the template reads - the same + * way the agent's own list works - so one row's typing never fills another's. + */ + private setGuarantorOptions(group: AbstractControl, options: any[]): void { + (group as any).clientOptions = options; + } + + guarantorOptionsFor(group: AbstractControl): any[] { + return (group as any).clientOptions || GUARANTOR_NO_OPTIONS; + } + + /** + * Opens the client list on a guarantor row as soon as it is focused, as the agent's field does. + * The list is filled before the panel is asked to open, since a panel opened empty stays shut. + */ + onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { + if (trigger) { + // Kept so a search answered later can open this row's list too. + (group as any).panel = { trigger, input }; + } + if (group.get('clientId')?.value) { + return; + } + const show = () => { + this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); + const panel = (group as any).panel; + this.openWhenFocused(panel?.trigger, panel?.input); + }; + if (this.initialClients.length > 0) { + show(); + return; + } + this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + show(); + }, + error: () => undefined + }); + } + + /** Clients who can stand guarantor on this row: not the agent, and not already on another row. */ + private eligibleGuarantors(group: AbstractControl, found: any[]): any[] { + const agentClientId = String(this.agentForm.get('clientId')?.value ?? ''); + const taken = new Set( + this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? '')) + ); + return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id))); + } + + /** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */ + private notAlreadyAGuarantor(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!control.value || !this.agentForm) { + return null; + } + const repeated = this.guarantors.controls.some( + (row) => row.get('clientId') !== control && String(row.get('clientId')?.value) === String(control.value) + ); + return repeated ? { duplicateGuarantor: true } : null; + }; + } + /** * An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes * from someone else. @@ -312,7 +511,14 @@ export class CreateAgentComponent implements OnInit { /** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */ private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void { - for (const controlName of ['guarantorName', 'telephone', 'address1', 'address2', 'city', 'pinCode']) { + for (const controlName of [ + 'guarantorName', + 'telephone', + 'address1', + 'address2', + 'city', + 'pinCode' + ]) { group.get(controlName)?.setValue(''); } if (!clientId) { @@ -343,11 +549,18 @@ export class CreateAgentComponent implements OnInit { } addGuarantor(): void { - this.guarantors.push(this.buildGuarantorGroup()); + const group = this.buildGuarantorGroup(); + this.guarantors.push(group); + // Ready before the field is ever focused, so the list is there the moment it is clicked. + this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); } removeGuarantor(index: number): void { this.guarantors.removeAt(index); + // A row that clashed with the removed one no longer does. + for (const row of this.guarantors.controls) { + row.get('clientId')?.updateValueAndValidity({ emitEvent: false }); + } } /** The fields the legacy screen filled from the client, rather than asking the officer for. */ @@ -360,6 +573,60 @@ export class CreateAgentComponent implements OnInit { 'agentPinCode' ]; + /** Opens the client list when the field is focused, unless a client is already in it. */ + onClientFocus(): void { + if (this.agentId || this.agentForm.get('clientId')?.value) { + return; + } + this.showClientList(); + } + + /** Shows the first page of clients, fetching it the first time it is asked for. */ + private showClientList(): void { + if (this.agentId) { + return; + } + if (this.initialClients.length > 0) { + this.showingClientList = true; + this.clientOptions = this.notYetAgents(this.initialClients); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + return; + } + // The chosen branch's clients only, since an agent is opened for the branch they belong to. + this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + this.showingClientList = true; + this.clientOptions = this.notYetAgents(this.initialClients); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + }, + // A failed list is not a failed screen - the id can still be typed. + error: () => (this.clientOptions = []) + }); + } + + private searchClients(term: string): void { + searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => { + this.showingClientList = false; + this.clientOptions = this.notYetAgents(found).slice(0, 25); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + }); + } + + /** + * Shows a list that has just arrived. An autocomplete opened while its list was still empty stays + * shut when the options come in, so the screen is refreshed and the panel opened again - but only + * on the field still being typed in, never one the officer has moved on from. + */ + private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { + this.changeDetector.detectChanges(); + if (trigger && input && document.activeElement === input) { + trigger.openPanel(); + } + } + /** * Refills the client-derived fields whenever the Client ID changes, as the legacy screen did. * They always show the client currently entered: changing the id replaces them, clearing it @@ -370,6 +637,7 @@ export class CreateAgentComponent implements OnInit { return; } this.clearClientDerivedFields(); + this.agentForm.get('agentBirthDate')?.setValue(''); this.loadedClientName = ''; this.clientLookupFailed = false; // A different client has different accounts, so anything picked for the previous one is stale. @@ -378,22 +646,42 @@ export class CreateAgentComponent implements OnInit { this.agentForm.get('agentSavingAccountType')?.setValue(''); this.agentForm.get('agentSavingAccountNo')?.setValue(''); + this.clientOtherBranch = false; + this.clientAlreadyAgent = ''; + this.clientAgeProblem = ''; const clientId = this.agentForm.get('clientId')?.value; if (!clientId) { return; } + // One client is one agent: someone already on the books is not made a second agent. + this.clientAlreadyAgent = this.agentCodeByClientId[String(clientId)] || ''; + if (this.clientAlreadyAgent) { + return; + } this.clientsService.getClientData(String(clientId), true).subscribe({ next: (client: any) => { + // An agent is opened at the branch chosen, from that branch's own clients. + if (this.branchOfficeId && client?.officeId && Number(client.officeId) !== this.branchOfficeId) { + this.clientOtherBranch = true; + this.loadedClientName = ''; + return; + } this.loadedClientName = client?.displayName || ''; this.setField('agentNameId', client?.displayName); this.setField('agentTelephone', client?.mobileNo); - // The branch the person is on the bank's books at. Offered as a starting point rather than - // forced, since an agent can be recorded at one office and collect out of another. Set as a - // number rather than through setField, whose String() would stop it matching an option. - if (client?.officeId) { - this.agentForm.get('officeId')?.setValue(Number(client.officeId)); - } + // The client's own date of birth, already taken and checked when the client was opened - + // copied across rather than keyed again. + const birthDate = asDate(client?.dateOfBirth); + this.agentForm.get('agentBirthDate')?.setValue(birthDate); + // Agents are taken on at 16 to 59 - said here, before the rest is filled in, not on Save. + const age = ageOn(birthDate, new Date()); + this.clientAgeProblem = + age === null + ? 'This client has no date of birth on record' + : age < CreateAgentComponent.MIN_AGENT_AGE || age >= CreateAgentComponent.MAX_AGENT_AGE + ? `Age is ${age}. An agent must be ${CreateAgentComponent.MIN_AGENT_AGE} to ${CreateAgentComponent.MAX_AGENT_AGE - 1} years old` + : ''; this.loadClientAddress(String(clientId)); this.loadClientSavingsAccounts(String(clientId)); }, @@ -429,6 +717,11 @@ export class CreateAgentComponent implements OnInit { this.savingsAccountTypes = [ ...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean)) ]; + for (const account of this.clientSavingsAccounts) { + if (account.productName && account.shortProductName) { + this.savingsAccountTypeCodes[account.productName] = account.shortProductName; + } + } }, error: () => { this.clientSavingsAccounts = []; @@ -539,10 +832,23 @@ export class CreateAgentComponent implements OnInit { })) }; + const done = (response: any) => { + // Under maker-checker the command is only recorded, and nothing has been saved yet. + if (!!response?.commandId && response?.resourceId == null) { + this.alertService.alert({ + type: 'Sent for Approval', + message: this.agentId + ? `Changes to agent ${raw.agentNameId} were sent for a manager's approval and will take effect once approved.` + : `New agent ${raw.agentNameId} was sent for a manager's approval and will be created once approved.` + }); + } + this.exit(); + }; + if (this.agentId) { - this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit()); + this.organizationService.updateAgent(this.agentId, payload).subscribe(done); } else { - this.organizationService.createAgent(payload).subscribe(() => this.exit()); + this.organizationService.createAgent(payload).subscribe(done); } } @@ -591,3 +897,33 @@ export class CreateAgentComponent implements OnInit { } } } + +/** A date as the platform sends it - [yyyy, mm, dd] or an ISO string - or null when there is none. */ +function asDate(value: any): Date | null { + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + if (value) { + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */ +const GUARANTOR_NO_OPTIONS: any[] = []; + +/** Full years between a date of birth and a day, or null without a date of birth. */ +function ageOn(birthDate: Date | null, on: Date): number | null { + if (!birthDate) { + return null; + } + let age = on.getFullYear() - birthDate.getFullYear(); + const birthdayPassed = + on.getMonth() > birthDate.getMonth() || + (on.getMonth() === birthDate.getMonth() && on.getDate() >= birthDate.getDate()); + if (!birthdayPassed) { + age--; + } + return age; +} diff --git a/src/app/organization/agent-master/offices-under.ts b/src/app/organization/agent-master/offices-under.ts new file mode 100644 index 000000000..1129ce228 --- /dev/null +++ b/src/app/organization/agent-master/offices-under.ts @@ -0,0 +1,27 @@ +/** + * 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/. + */ + +/** + * The given office and every office under it, read from the office hierarchy (".", ".2.", ".2.5."): + * an office's own branches are the ones whose hierarchy starts with its own. Head Office, at the + * top, has them all. Falls back to the office alone when its hierarchy is not known. + */ +export function officesUnder(offices: any[], officeId: number | null | undefined): any[] { + if (!officeId) { + return offices; + } + const own = offices.find((office: any) => office.id === officeId); + if (!own) { + return []; + } + const hierarchy = String(own.hierarchy || ''); + if (!hierarchy) { + return [own]; + } + return offices.filter((office: any) => String(office.hierarchy || '').startsWith(hierarchy)); +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.ts b/src/app/organization/commission-slab-master/commission-slab-master.component.ts index c1a96c849..6519a1193 100644 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.ts +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.ts @@ -15,6 +15,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; import { AlertService } from 'app/core/alert/alert.service'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; /** * Commission Slab Master component. @@ -144,9 +145,9 @@ export class CommissionSlabMasterComponent implements OnInit { private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any { return this.slabs.find( (slab) => - slab.slabType === candidate.slabType - && Number(candidate.slabFrom) <= Number(slab.slabTo) - && Number(candidate.slabTo) >= Number(slab.slabFrom) + slab.slabType === candidate.slabType && + Number(candidate.slabFrom) <= Number(slab.slabTo) && + Number(candidate.slabTo) >= Number(slab.slabFrom) ); } @@ -257,8 +258,12 @@ export class CommissionSlabMasterComponent implements OnInit { locale: 'en' }) .subscribe({ - next: () => { - this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); + next: (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService); + } else { + this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); + } this.editingIndex = -1; this.loadSlabs(); }, @@ -268,7 +273,9 @@ export class CommissionSlabMasterComponent implements OnInit { this.alertService.alert({ type: 'Error', message: - error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab' + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not correct the slab' }); } }); @@ -298,11 +305,20 @@ export class CommissionSlabMasterComponent implements OnInit { })); this.organizationService.createCommissionSlabs(payload).subscribe({ - next: () => { - this.alertService.alert({ - type: 'Success', - message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved` - }); + next: (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'}`, + this.organizationService, + this.alertService + ); + } else { + this.alertService.alert({ + type: 'Success', + message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved` + }); + } this.loadSlabs(); }, // The backend refuses an overlapping range; the officer needs to read why, not watch nothing @@ -311,7 +327,9 @@ export class CommissionSlabMasterComponent implements OnInit { this.alertService.alert({ type: 'Error', message: - error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs' + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not save the slabs' }); } }); diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts index a4ed266a6..37a2b5ee5 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -22,6 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service'; import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; /** * DDS Interest & Commission batch processing — the legacy Interest Part menu. @@ -264,11 +265,11 @@ export class DdsBatchProcessingComponent implements OnInit { type: 'warn', heading: `Post ${this.jobTypeLabel}?`, dialogContext: - `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` - + (isPosting + `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` + + (isPosting ? 'This credits the customers or pays the agents, and appears on the ledger. ' - : 'This records what has been earned against each account. ') - + 'It cannot be undone from this screen.' + : 'This records what has been earned against each account. ') + + 'It cannot be undone from this screen.' } }) .afterClosed() @@ -296,11 +297,21 @@ export class DdsBatchProcessingComponent implements OnInit { const runId = this.generatedRun.id; const crediting = this.isPostingJob; - this.organizationService.postDdsBatchRun(runId).subscribe(() => { + this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => { this.generatedRun = null; this.previewRows = []; this.loadRecentRuns(); + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`, + this.organizationService, + this.alertService + ); + return; + } + if (!crediting) { this.alertService.alert({ type: 'Success', @@ -312,8 +323,9 @@ export class DdsBatchProcessingComponent implements OnInit { this.postingRunId = runId; this.alertService.alert({ type: 'Posting', - message: `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` - + 'It continues in the background; this list will update when it is done.' + message: + `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` + + 'It continues in the background; this list will update when it is done.' }); this.followRun(runId, label); }); diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 91030b2d9..547cae9b6 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -339,6 +339,37 @@ export class OrganizationService { * @param {string | number} clientId Client ID. * @returns {Observable} id, accountNo, productName and balance for each. */ + /** The liability GL accounts a DDS balance can be parked in, for the clerk to search and pick. */ + getDdsSundryAccounts(): Observable { + return this.http.get('/dds/parties/sundryaccounts'); + } + + /** DDS entries waiting on (or already past) a checker - PENDING, APPROVED or REJECTED. */ + getDdsApprovals(status: string, fromDate?: string, toDate?: string): Observable { + const params: any = { status }; + if (fromDate) { + params.fromDate = fromDate; + } + if (toDate) { + params.toDate = toDate; + } + return this.http.get('/dds/approvals', { params }); + } + + getDdsApproval(commandId: string | number): Observable { + return this.http.get(`/dds/approvals/${commandId}`); + } + + /** Approves or rejects a DDS entry; an approval returns the voucher it moved under. */ + actOnDdsApproval(commandId: string | number, command: 'approve' | 'reject'): Observable { + return this.http.post(`/dds/approvals/${commandId}`, {}, { params: { command } }); + } + + /** How a closed DDS account was closed: by whom, what it came to and where the money went. */ + getPartyClosure(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}/closure`); + } + getCreditableAccounts(clientId: string | number): Observable { return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } }); } @@ -376,6 +407,14 @@ export class OrganizationService { return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand); } + /** + * Closes a DDS account early inside its scheme's lock-in, for the reason given. Its own permission, + * so it goes to a manager for approval; the scheme's lock-in is not changed. + */ + prematureCloseOverrideParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=prematurecloseoverride`, closeCommand); + } + /** * @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post. * @returns {Observable} @@ -404,6 +443,26 @@ export class OrganizationService { return this.http.post('/dds/collectionentries/settle', settlement); } + /** Everything the agent still has outstanding, whatever day it was written - what a receipt is applied against. */ + getAgentPendingCollections(agentId: any): Observable { + return this.http.get(`/dds/collectionentries/pending?agentId=${agentId}`); + } + + /** + * The cashier's receipts into the agent's GL account: the DDS receipts ready to apply, or - with a + * voucher number - that one voucher in whatever state, so the screen can say why it cannot be used. + */ + getAgentCashReceipts(agentId: any, voucherNo?: string, includeWaiting = false): Observable { + const voucher = voucherNo ? `&voucherNo=${encodeURIComponent(voucherNo)}` : ''; + const waiting = includeWaiting ? '&includeWaiting=true' : ''; + return this.http.get(`/dds/collectionentries/cashreceipts?agentId=${agentId}${voucher}${waiting}`); + } + + /** A cashier receipt's own Dr/Cr lines. */ + getCashReceiptGlEntries(receiptId: any): Observable { + return this.http.get(`/dds/collectionentries/cashreceipts/${receiptId}/glentries`); + } + /** * An agent's DDS transactions. The daily entry screen uses this to show what has already been * keyed for the agent today, so the same collection book is not entered twice. @@ -417,6 +476,16 @@ export class OrganizationService { }); } + /** + * @param {string | number} partyId DDS account (party) id. + * @returns {Observable} Everything posted to that DDS account, most recent first. + */ + getPartyTransactionsOfParty(partyId: string | number): Observable { + return this.http.get('/dds/partytransactions', { + params: new HttpParams().set('partyId', String(partyId)) + }); + } + /** * @returns {Observable} DDS Commission Slabs data */ diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html index 761aa3737..3e0eff78f 100644 --- a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html @@ -11,15 +11,15 @@
-

Account Type Creation

-

{{ isEdit ? 'Update this Daily Deposit scheme variant.' : 'Register a Daily Deposit scheme variant.' }}

+

DDS Product Creation

+

{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}

- Account Type Group + Product Group @for (group of accountTypeGroups; track group) { {{ group }} @@ -28,15 +28,24 @@ - A/c.Type + Product Code - A/c. Type Description + Product Description + + Interest Rate (%) + + Interest is worked out daily on the balance (rate ÷ 365) + @if (accountTypeForm.get('interestRate')?.hasError('required')) { + Interest rate is required + } + + Auto Interest Post @@ -84,8 +93,8 @@ -->

Premature Closure

- Applied when an account on this scheme is closed before its term ends. The branch chooses at the time - whether the customer or the agent bears the cost; these set what that costs. + Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether + the customer or the agent bears the cost; these set what that costs.

@@ -97,26 +106,36 @@ Customer: interest rate reduced by (%) - + Taken off the rate for the whole period, not only the part left to run. Agent: commission reduced by (%) - + Charged whoever bears the interest — the round was not kept going.
- diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts index dc7911ba3..0cdbcf188 100644 --- a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts @@ -16,7 +16,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Services */ import { ProductsService } from 'app/products/products.service'; import { AlertService } from 'app/core/alert/alert.service'; +import { OrganizationService } from 'app/organization/organization.service'; import { SettingsService } from 'app/settings/settings.service'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; /** * Account Type Creation — the legacy DDS screen this replaces, field for field: group, code, @@ -40,6 +42,7 @@ export class CreateDdsAccountTypeComponent implements OnInit { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); private alertService = inject(AlertService); + private organizationService = inject(OrganizationService); private settingsService = inject(SettingsService); accountTypeGroups = ['DDS']; @@ -85,6 +88,15 @@ export class CreateDdsAccountTypeComponent implements OnInit { false, Validators.required ], + // Mandatory: every account opened on this scheme takes this rate, rather than one typed per customer. + interestRate: [ + '', + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], /* * What this scheme charges when an account is closed before its term is up. Set here rather than * asked of the clerk at closing time: the terms are the bank's, and the same for every account on @@ -157,6 +169,7 @@ export class CreateDdsAccountTypeComponent implements OnInit { autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly', autoInterestPostDay: accountType.autoInterestPostDay ?? '', maturityAutoClose: !!accountType.maturityAutoClose, + interestRate: accountType.interestRate ?? '', // ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one // that charges nothing - and must not be replaced by the default. prematureLockinMonths: accountType.prematureLockinMonths ?? 12, @@ -178,8 +191,10 @@ export class CreateDdsAccountTypeComponent implements OnInit { const payload = { ...raw, // The posting day only applies while auto-posting is on; send nothing for it otherwise. - autoInterestPostDay: raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined, + autoInterestPostDay: + raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined, // Sent as numbers, not the strings a text input yields - the backend reads them as decimals. + interestRate: Number(raw.interestRate), prematureLockinMonths: Number(raw.prematureLockinMonths), prematureInterestReduction: Number(raw.prematureInterestReduction), prematureAgentCommissionPenalty: Number(raw.prematureAgentCommissionPenalty), @@ -187,13 +202,22 @@ export class CreateDdsAccountTypeComponent implements OnInit { locale: this.settingsService.language.code }; - const done = () => { - this.alertService.alert({ - type: 'Success', - message: this.isEdit - ? `Account type ${payload.accountTypeCode} updated` - : `Account type ${payload.accountTypeCode} created` - }); + const done = (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `DDS product ${payload.accountTypeCode} ${this.isEdit ? 'change' : ''}`.trim(), + this.organizationService, + this.alertService + ); + } else { + this.alertService.alert({ + type: 'Success', + message: this.isEdit + ? `Account type ${payload.accountTypeCode} updated` + : `Account type ${payload.accountTypeCode} created` + }); + } this.backToList(); }; diff --git a/src/app/products/dds-account-types/dds-account-types.component.html b/src/app/products/dds-account-types/dds-account-types.component.html index 5fbea59c9..cea8c4202 100644 --- a/src/app/products/dds-account-types/dds-account-types.component.html +++ b/src/app/products/dds-account-types/dds-account-types.component.html @@ -11,8 +11,8 @@
-

DDS Account Types

-

Scheme variants a Daily Deposit account can be opened under.

+

DDS Product

+

The Daily Deposit products a customer account can be opened under.

@@ -23,7 +23,7 @@
@@ -33,7 +33,7 @@
{{ totalCount | number }}
-
Total Account Types
+
Total DDS Products
@@ -52,7 +52,7 @@
@@ -64,14 +64,14 @@

Create one so Daily Deposit accounts can be opened against it.

} @else {
- + - + @@ -250,6 +253,129 @@ } + @if (showOutsideApproval) { +
+
+
+

Fixed deposit interest posted outside this report

+
+ A deposit that matured before its own posting cycle completed has its last period settled directly, the + moment it matures — before any report here could be generated or approved for it. Listed here so + nothing paid to a customer is invisible, approved or not. +
+
+
+ Total + ₹{{ outsideApprovalTotal | number: '1.2-2' }} + {{ outsideApprovalRows.length }} posting(s) +
+
+
+ + + + +
+ @if (outsideApprovalLoading) { +
Looking for deposit interest posted outside this report…
+ } @else { +
+
A/c TypeProduct
diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index d043fd8a5..52d438830 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -737,7 +737,7 @@ const routes: Routes = [ }, { path: 'dds-account-types', - data: { title: 'DDS Account Types', breadcrumb: 'DDS Account Types' }, + data: { title: 'DDS Product', breadcrumb: 'DDS Product' }, children: [ { path: '', @@ -746,12 +746,12 @@ const routes: Routes = [ { path: 'create', component: CreateDdsAccountTypeComponent, - data: { title: 'Create Account Type', breadcrumb: 'Create' } + data: { title: 'Create DDS Product', breadcrumb: 'Create' } }, { path: ':accountTypeId/edit', component: CreateDdsAccountTypeComponent, - data: { title: 'Edit Account Type', breadcrumb: 'Edit', routeParamBreadcrumb: false } + data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false } } ] }, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index 766211fa5..77186ce4c 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -131,8 +131,10 @@
-

DDS Account Types

-

Create a Daily Deposit Scheme account type — group, code and auto-interest posting

+

DDS Product

+

+ Create a Daily Deposit Scheme account type — group, code and auto-interest posting +

diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 03f8fa26a..3ae13cd26 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -200,6 +200,10 @@ Disbursement & Transfers + + + DDS Approvals + Date: Mon, 5 Oct 2026 16:20:36 +0530 Subject: [PATCH 05/21] feat(loan-products): add loan interest rate register UI - Add Loan Interest Rate Register screen with CIBIL adjustment and edit-register views, backed by rate-register service and model - Wire the new screen into products routing and the products menu - Update loan product details and advance limit/tenure steps Co-Authored-By: Claude Sonnet 5.5 --- .../cibil-adjustment.component.html | 166 +++++++ .../cibil-adjustment.component.scss | 344 +++++++++++++ .../cibil-adjustment.component.ts | 162 +++++++ .../edit-register.component.html | 114 +++++ .../edit-register.component.scss | 323 +++++++++++++ .../edit-register/edit-register.component.ts | 196 ++++++++ ...loan-interest-rate-register.component.html | 83 ++++ ...loan-interest-rate-register.component.scss | 212 ++++++++ .../loan-interest-rate-register.component.ts | 78 +++ .../models/rate-register.model.ts | 52 ++ .../rate-register.service.ts | 457 ++++++++++++++++++ ...uct-advance-limit-tenure-step.component.ts | 2 +- .../loan-product-details-step.component.html | 6 +- .../loan-product-details-step.component.ts | 32 +- src/app/products/products-routing.module.ts | 23 + src/app/products/products.component.html | 13 + 16 files changed, 2252 insertions(+), 11 deletions(-) create mode 100644 src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html create mode 100644 src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss create mode 100644 src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts create mode 100644 src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html create mode 100644 src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss create mode 100644 src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts create mode 100644 src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html create mode 100644 src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss create mode 100644 src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts create mode 100644 src/app/products/loan-interest-rate-register/models/rate-register.model.ts create mode 100644 src/app/products/loan-interest-rate-register/rate-register.service.ts diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html new file mode 100644 index 000000000..92b80c9de --- /dev/null +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html @@ -0,0 +1,166 @@ + + +
+
+
CS
+
+

CIBIL / credit score adjustment

+

Applied on top of the chart rate at loan sanction · reused across all loan products linked to this register

+
+ +
+ +
+
+
+
+
Credit score slabs
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }} + {{ slab.tier }} + {{ slab.type }} + {{ formatPct(slab.adjustmentPct) }} + ⋯
+
+ +
+
+
Fallback behaviour
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+
Live preview
+
pick a scenario
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
RATE DERIVATION
+
+ Chart cell — {{ productLabel(previewProductSrNo) }} + {{ + chartCell() !== null ? (chartCell() | number: '1.2-2') + '%' : 'N.A.' + }} +
+
+ CIBIL {{ previewCibil }} ({{ cibilSlab()?.tier ?? '—' }}) + + {{ formatPct(cibilSlab()?.adjustmentPct ?? 0) }} + +
+
+ Product floor / ceiling + no clamp +
+
+
+ Final rate + {{ finalRate() }} +
+
+
+
+ +
+
+
What changes if…
+
+
+ CIBIL = 700 + {{ rateFor(700) }} +
+
+ CIBIL = 870 + {{ rateFor(870) }} +
+
+ No CIBIL fetched + {{ rateForFallback() }} +
+
+
+
+
+
+ + +
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss new file mode 100644 index 000000000..243151aa0 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss @@ -0,0 +1,344 @@ +/** + * 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/. + */ + +.ca { + background: #f8fafc; + min-height: calc(100vh - 160px); + padding: 20px 32px; + display: flex; + flex-direction: column; + gap: 16px; + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + } + + &__header-icon { + width: 40px; + height: 40px; + border-radius: 9px; + background: #dbeafe; + color: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 700; + } + + &__header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: #64748b; + } + } + + &__body { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 16px; + } + + &__left, + &__right { + display: flex; + flex-direction: column; + gap: 14px; + } + + &__card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + } + + &__card-head { + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + display: flex; + align-items: center; + gap: 10px; + } + + &__card-title { + font-size: 14px; + font-weight: 600; + } + + &__card-hint { + margin-left: auto; + font-size: 11px; + color: #64748b; + } + + &__card-body { + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 12px; + } + + &__dash-btn { + margin-left: auto; + height: 28px; + font-size: 11px; + font-weight: 600; + color: #1d4ed8; + border: 1px dashed #1d4ed8; + + i { + margin-right: 3px; + } + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + thead tr { + background: #f8fafc; + color: #64748b; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; + + th { + text-align: left; + padding: 9px 14px; + font-weight: 600; + } + } + + tbody tr { + border-top: 1px solid #f1f5f9; + + td { + padding: 11px 14px; + } + } + } + + &__row-alt { + background: #fafbfc; + } + + &__num { + text-align: right; + } + + &__dots { + text-align: center; + color: #94a3b8; + cursor: pointer; + } + + &__fallback-grid, + &__preview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + } + + &__field { + display: flex; + flex-direction: column; + + label { + font-size: 11px; + color: #64748b; + font-weight: 600; + letter-spacing: 0.3px; + margin-bottom: 4px; + } + + input, + select { + height: 36px; + border: 1px solid #cbd5e1; + background: white; + border-radius: 6px; + padding: 0 12px; + font-size: 13px; + color: #0f172a; + font-family: inherit; + + &:focus { + outline: 2px solid #1d4ed8; + outline-offset: -1px; + } + } + } + + &__derivation { + padding: 12px 14px; + background: #f8fafc; + border-radius: 8px; + } + + &__derivation-label { + font-size: 11px; + color: #64748b; + font-weight: 600; + letter-spacing: 0.3px; + margin-bottom: 8px; + } + + &__derivation-row { + display: flex; + justify-content: space-between; + font-size: 12px; + padding: 3px 0; + + &--muted { + color: #64748b; + } + } + + &__derivation-val { + font-weight: 600; + + &--pos { + color: #059669; + } + + &--neg { + color: #dc2626; + } + } + + &__divider { + height: 1px; + background: #e2e8f0; + margin: 8px 0; + } + + &__final { + display: flex; + justify-content: space-between; + align-items: center; + + span:first-child { + font-size: 12px; + font-weight: 600; + } + } + + &__final-val { + font-size: 20px; + font-weight: 700; + color: #1d4ed8; + } + + &__scenarios { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + color: #334155; + } + + &__scenario { + padding: 8px 10px; + background: #f8fafc; + border-radius: 6px; + display: flex; + justify-content: space-between; + + span:last-child { + font-weight: 600; + } + } + + &__scenario-val--pos { + color: #059669; + } + + &__scenario-val--warn { + color: #d97706; + } + + &__footer { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__footer-note { + font-size: 11px; + color: #64748b; + } +} + +.tier { + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + + &--poor { + background: #fee2e2; + color: #991b1b; + } + + &--fair { + background: #fef3c7; + color: #92400e; + } + + &--good { + background: #dbeafe; + color: #1e40af; + } + + &--vgood { + background: #dcfce7; + color: #166534; + } + + &--excellent { + background: #d1fae5; + color: #065f46; + } +} + +.adj { + font-weight: 600; + + &--negative { + color: #059669; + } + + &--positive { + color: #d97706; + } + + &--high { + color: #dc2626; + } +} diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts new file mode 100644 index 000000000..4a183cabf --- /dev/null +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts @@ -0,0 +1,162 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { RateRegisterService } from '../rate-register.service'; +import { CibilSlab, RateRegister } from '../models/rate-register.model'; + +/** + * CIBIL/credit-score adjustment editor for a Rate Register — slab table, fallback behaviour + * and a live preview that resolves {product, loan amount, CIBIL} down to the final rate. + */ +@Component({ + selector: 'mifosx-cibil-adjustment', + templateUrl: './cibil-adjustment.component.html', + styleUrls: ['./cibil-adjustment.component.scss'], + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule + ] +}) +export class CibilAdjustmentComponent implements OnInit { + private readonly service = inject(RateRegisterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + register!: RateRegister; + + // Live preview inputs. + previewProductSrNo = 7; // House Purchase / Construction + previewAmount = 2500000; + previewCibil = 820; + previewTenureMonths = 120; + + ngOnInit(): void { + const idParam = this.route.snapshot.queryParamMap.get('id'); + const id = idParam ? Number(idParam) : null; + this.service.list().subscribe((all) => { + this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + }); + } + + tierClass(tier: CibilSlab['tier']): string { + const map: Record = { + Poor: 'tier tier--poor', + Fair: 'tier tier--fair', + Good: 'tier tier--good', + 'V. Good': 'tier tier--vgood', + Excellent: 'tier tier--excellent' + }; + return map[tier] ?? 'tier'; + } + + adjustmentClass(pct: number): string { + if (pct < 0) { + return 'adj adj--negative'; + } + if (pct > 0) { + return pct >= 1 ? 'adj adj--high' : 'adj adj--positive'; + } + return 'adj'; + } + + formatPct(pct: number): string { + if (pct === 0) { + return '0.00%'; + } + const sign = pct > 0 ? '+' : '−'; + return `${sign}${Math.abs(pct).toFixed(2)}%`; + } + + /** Looks up the chart cell for the currently previewed scenario. */ + private chartRateFor(productSrNo: number, amount: number): number | null { + const row = this.register.rows.find((r) => r.srNo === productSrNo); + if (!row) { + return null; + } + for (let i = 0; i < this.register.slabs.length; i++) { + const slab = this.register.slabs[i]; + const upto = slab.amountUpto; + if (upto === null || amount <= upto) { + const cell = row.rates[i]; + if (cell === null) { + return null; + } + if (typeof cell === 'string') { + // Formula cell — in a real derive we'd resolve against the pledged deposit's rate. + return null; + } + return cell; + } + } + return null; + } + + private cibilAdjustmentFor(score: number): CibilSlab | null { + return this.register.cibilSlabs.find((s) => score >= s.from && score <= s.to) ?? null; + } + + chartCell(): number | null { + return this.chartRateFor(this.previewProductSrNo, this.previewAmount); + } + + cibilSlab(): CibilSlab | null { + return this.cibilAdjustmentFor(this.previewCibil); + } + + finalRate(): string { + const base = this.chartCell(); + const slab = this.cibilSlab(); + if (base === null) { + return 'N.A.'; + } + const adjustment = slab?.adjustmentPct ?? 0; + return `${(base + adjustment).toFixed(2)}% p.a.`; + } + + productLabel(srNo: number): string { + return this.register.rows.find((r) => r.srNo === srNo)?.particulars ?? '—'; + } + + rateFor(cibil: number): string { + const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount); + if (base === null) { + return 'N.A.'; + } + const slab = this.cibilAdjustmentFor(cibil); + return `${(base + (slab?.adjustmentPct ?? 0)).toFixed(2)}%`; + } + + rateForFallback(): string { + const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount); + if (base === null) { + return 'N.A.'; + } + const fallback = this.register.cibilSlabs[this.register.fallbackNoCibilSlab - 1]; + return `${(base + (fallback?.adjustmentPct ?? 0)).toFixed(2)}%`; + } + + backToRegister(): void { + this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } }); + } + + save(): void { + this.backToRegister(); + } +} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html new file mode 100644 index 000000000..02d72204d --- /dev/null +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -0,0 +1,114 @@ + + +
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss new file mode 100644 index 000000000..e784c658d --- /dev/null +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss @@ -0,0 +1,323 @@ +/** + * 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/. + */ + +.er { + background: #f8fafc; + min-height: calc(100vh - 160px); + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; + + &__meta-bar { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + padding: 12px 14px; + display: flex; + gap: 10px; + align-items: flex-end; + } + + &__field { + display: flex; + flex-direction: column; + flex: 1.5; + + label { + font-size: 10px; + color: #64748b; + font-weight: 600; + letter-spacing: 0.3px; + margin-bottom: 3px; + } + + input, + select { + height: 32px; + border: 1px solid #cbd5e1; + background: white; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + font-family: inherit; + color: #0f172a; + + &:focus { + outline: 2px solid #1d4ed8; + outline-offset: -1px; + } + } + + &--date { + flex: 0.9; + } + + &--curr { + flex: 0.5; + } + } + + &__meta-btn { + height: 32px; + font-size: 11px; + + i { + margin-right: 4px; + } + } + + &__grid-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-height: 500px; + } + + &__grid-head { + padding: 8px 14px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 10px; + } + + &__grid-title { + font-size: 12px; + font-weight: 600; + } + + &__grid-sub { + font-size: 10px; + color: #64748b; + } + + &__grid-actions { + margin-left: auto; + display: flex; + gap: 6px; + } + + &__dash-btn { + height: 26px; + font-size: 10px; + font-weight: 600; + color: #1d4ed8; + border: 1px dashed #1d4ed8; + + i { + margin-right: 3px; + } + } + + &__grid-scroll { + overflow: auto; + flex: 1; + } + + &__grid { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 11px; + + thead tr { + background: #f8fafc; + color: #475569; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.3px; + + th { + padding: 8px 7px; + font-weight: 600; + position: sticky; + top: 0; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + z-index: 2; + white-space: nowrap; + } + } + + tbody tr { + border-top: 1px solid #f1f5f9; + } + + td { + padding: 7px; + text-align: right; + cursor: text; + + &:first-child { + text-align: left; + } + } + } + + &__col-particulars { + position: sticky; + left: 0; + background: white; + border-right: 1px solid #e2e8f0; + font-weight: 500; + min-width: 240px; + text-align: left !important; + padding-left: 12px !important; + } + + &__row-alt &__col-particulars { + background: #fafbfc; + } + + &__row-alt { + background: #fafbfc; + } + + &__row--formula { + .er__col-particulars { + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; + } + } + + &__srno { + color: #94a3b8; + margin-right: 6px; + } + + &__cell { + cursor: cell; + transition: background-color 0.1s; + + &:hover { + background: #f1f5f9; + outline: 1px solid #cbd5e1; + outline-offset: -1px; + } + + &--na { + color: #cbd5e1; + } + + &--formula { + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; + } + + &--editing { + padding: 0 !important; + background: #fef9c3 !important; + outline: 2px solid #1d4ed8 !important; + outline-offset: -2px; + } + } + + &__cell-input { + width: 100%; + height: 100%; + min-height: 28px; + border: none; + outline: none; + background: transparent; + padding: 7px; + font-size: 11px; + text-align: right; + font-family: inherit; + color: #0f172a; + box-sizing: border-box; + } + + &__cibil-strip { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + padding: 9px 14px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + transition: background-color 0.1s; + + &:hover { + background: #f8fafc; + } + } + + &__cibil-badge { + width: 24px; + height: 24px; + border-radius: 12px; + background: #dbeafe; + color: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + } + + &__cibil-text { + flex: 1; + } + + &__cibil-title { + font-size: 12px; + font-weight: 600; + } + + &__cibil-sub { + font-size: 10px; + color: #64748b; + margin-top: 1px; + } + + &__cibil-edit { + font-size: 11px; + font-weight: 600; + color: #1d4ed8; + cursor: pointer; + } + + &__footer { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + padding: 10px 14px; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__footer-note { + font-size: 11px; + color: #64748b; + } + + &__footer-actions { + display: flex; + gap: 6px; + } + + &__submit { + background: #059669 !important; + color: white !important; + font-weight: 600; + + i { + margin-right: 4px; + } + } +} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts new file mode 100644 index 000000000..3d57a6eb4 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts @@ -0,0 +1,196 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatNativeDateModule } from '@angular/material/core'; +import { RateRegisterService } from '../rate-register.service'; +import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; + +/** + * Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10 + * amount slabs as columns. Cells can hold a plain rate, N.A., or a formula ("1% + FD"). All + * data today comes from {@link RateRegisterService}; wire-up to the real API comes later. + */ +@Component({ + selector: 'mifosx-edit-register', + templateUrl: './edit-register.component.html', + styleUrls: ['./edit-register.component.scss'], + imports: [ + CommonModule, + FormsModule, + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatIconModule, + MatDatepickerModule, + MatNativeDateModule + ] +}) +export class EditRegisterComponent implements OnInit { + private readonly service = inject(RateRegisterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + register!: RateRegister; + slabs: SlabColumn[] = []; + rows: RateChartRow[] = []; + + /** The cell currently being edited (row/column indices into the grid), or null when nothing is open. */ + editing: { row: number; col: number } | null = null; + editValue = ''; + + @ViewChild('cellInput') cellInput?: ElementRef; + + ngOnInit(): void { + const idParam = this.route.snapshot.queryParamMap.get('id'); + const id = idParam ? Number(idParam) : null; + this.service.list().subscribe((all) => { + this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + this.slabs = this.register.slabs; + this.rows = this.register.rows; + }); + } + + /** Formatted display for a cell. */ + display(cell: RateCell): string { + if (cell === null) { + return 'N.A.'; + } + if (typeof cell === 'string') { + return cell; + } + return cell.toFixed(2); + } + + isFormulaRow(row: RateChartRow): boolean { + return !!row.isFormula; + } + + isNa(cell: RateCell): boolean { + return cell === null; + } + + /** Click on a cell → open it as an editable input, pre-filled with the current value. */ + startEdit(rowIdx: number, colIdx: number, current: RateCell): void { + this.editing = { row: rowIdx, col: colIdx }; + if (current === null) { + this.editValue = ''; + } else if (typeof current === 'string') { + this.editValue = current; + } else { + this.editValue = current.toString(); + } + // Let the view render the input, then focus and select its contents. + setTimeout(() => { + const el = this.cellInput?.nativeElement; + if (el) { + el.focus(); + el.select(); + } + }, 0); + } + + isEditing(rowIdx: number, colIdx: number): boolean { + return !!this.editing && this.editing.row === rowIdx && this.editing.col === colIdx; + } + + /** + * Save the cell: + * - empty / "N.A." / "NA" → the slab is marked unavailable + * - value with letters, "%" or "+" → persisted as a formula string ("1% + FD") + * - plain number → stored as a number so it formats as a % + */ + saveEdit(rowIdx: number, colIdx: number): void { + if (!this.editing) { + return; + } + const raw = this.editValue.trim(); + let next: RateCell; + if (!raw || raw.toLowerCase() === 'n.a.' || raw.toLowerCase() === 'na') { + next = null; + } else if (/[a-zA-Z+%]/.test(raw)) { + next = raw; + } else { + const num = Number(raw); + next = Number.isFinite(num) ? num : this.rows[rowIdx].rates[colIdx]; + } + this.rows[rowIdx].rates[colIdx] = next; + this.rows[rowIdx].isFormula = this.rows[rowIdx].rates.some((c) => typeof c === 'string'); + this.editing = null; + } + + cancelEdit(): void { + this.editing = null; + } + + /** Append a new empty product row at the bottom of the grid. */ + addProduct(): void { + const name = window.prompt('New product name', 'New product'); + if (!name) { + return; + } + const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1; + this.rows.push({ + srNo: next, + particulars: name.trim(), + rates: Array(this.slabs.length).fill(null), + isFormula: false + }); + } + + /** Append a new amount slab column; every existing row grows by one blank cell. */ + addSlab(): void { + const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab'); + if (!label) { + return; + } + const amountText = window.prompt('Amount upper bound in ₹ (leave blank for no cap)', ''); + const amountUpto = amountText && amountText.trim() ? Number(amountText) : null; + this.slabs.push({ + key: `s_${Date.now()}`, + label: label.trim(), + amountUpto: amountUpto !== null && !Number.isFinite(amountUpto) ? null : amountUpto + }); + this.rows.forEach((r) => r.rates.push(null)); + } + + editCibil(): void { + this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.register.id } }); + } + + backToList(): void { + this.router.navigate(['/products/loan-interest-rate-register']); + } + + submitForApproval(): void { + this.register.status = 'Active'; + this.router.navigate(['/products/loan-interest-rate-register']); + } + + statusChipClass(): string { + switch (this.register?.status) { + case 'Active': + return 'chip chip--active'; + case 'Draft': + return 'chip chip--draft'; + default: + return 'chip chip--expired'; + } + } +} diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html new file mode 100644 index 000000000..76909d598 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -0,0 +1,83 @@ + + +
+
+
+
+

Loan Interest Rate Register

+

Central CIBIL-based rate chart used across all loan products

+
+ + +
+ +
+
+
+
ACTIVE REGISTER
+
{{ active?.name ?? 'None' }}
+
Effective {{ activeWindow() }}
+
+
+
SLABS
+
10 amount slabs
+
₹10k → > ₹25L
+
+
+
CIBIL ADJUSTMENTS
+
{{ active?.cibilSlabs?.length ?? 0 }} slabs
+
+1.00% → −0.20%
+
+
+ +
+
+
All registers
+
+ + + + + + + + + + + + + + + + + + + + + + + +
NameEffective fromEffective toStatusCreated byLast modified
+
{{ r.name }}
+
{{ r.note }}
+
{{ r.effectiveFrom | date: 'dd MMM yyyy' }}{{ r.effectiveTo ? (r.effectiveTo | date: 'dd MMM yyyy') : '—' }} + {{ r.status === 'Active' ? '● Active' : r.status }} + {{ r.createdBy }}{{ r.lastModified | date: 'dd MMM yyyy' }}{{ r.status === 'Draft' ? 'Edit · Submit' : 'View' }}
+
+
Showing 1–{{ registers.length }} of {{ registers.length }}
+
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss new file mode 100644 index 000000000..1b930acd0 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss @@ -0,0 +1,212 @@ +/** + * 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/. + */ + +.lirr { + background: #f8fafc; + min-height: calc(100vh - 160px); + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 22px 32px 14px; + background: white; + border-bottom: 1px solid #e2e8f0; + } + + &__header-icon { + width: 40px; + height: 40px; + border-radius: 9px; + background: #eff6ff; + color: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + } + + &__header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: #64748b; + } + } + + &__excel-btn, + &__create-btn { + i { + margin-right: 6px; + } + } + + &__body { + padding: 20px 32px; + } + + &__kpi-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + margin-bottom: 18px; + } + + &__kpi { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 14px 16px; + } + + &__kpi-label { + font-size: 11px; + color: #64748b; + font-weight: 600; + letter-spacing: 0.3px; + } + + &__kpi-value { + font-size: 18px; + font-weight: 600; + margin-top: 4px; + } + + &__kpi-sub { + font-size: 11px; + color: #64748b; + margin-top: 4px; + + &--ok { + color: #059669; + } + } + + &__table-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + } + + &__table-head { + padding: 12px 20px; + border-bottom: 1px solid #f1f5f9; + display: flex; + align-items: center; + } + + &__table-title { + font-size: 14px; + font-weight: 600; + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + thead { + background: #f8fafc; + color: #64748b; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.3px; + + th { + text-align: left; + padding: 10px 12px; + font-weight: 600; + + &:first-child { + padding-left: 20px; + } + } + } + + tbody tr { + border-top: 1px solid #f1f5f9; + cursor: pointer; + transition: background-color 0.1s; + + &:hover { + background: #f8fafc; + } + + td { + padding: 12px; + + &:first-child { + padding-left: 20px; + } + } + } + } + + &__row--active { + background: #f0fdf4; + } + + &__name { + font-weight: 600; + } + + &__name-sub { + font-size: 11px; + color: #64748b; + margin-top: 2px; + } + + &__row-actions { + text-align: right; + color: #64748b; + padding-right: 20px !important; + } + + &__table-foot { + padding: 10px 20px; + border-top: 1px solid #f1f5f9; + font-size: 12px; + color: #64748b; + } +} + +.chip { + padding: 3px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + + &--active { + background: #dcfce7; + color: #166534; + } + + &--draft { + background: #fef3c7; + color: #92400e; + } + + &--expired { + background: #f1f5f9; + color: #475569; + } + + &--scheduled { + background: #e0e7ff; + color: #3730a3; + } +} diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts new file mode 100644 index 000000000..409735ebf --- /dev/null +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts @@ -0,0 +1,78 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RateRegisterService } from './rate-register.service'; +import { RateRegister } from './models/rate-register.model'; + +/** + * List screen for the central Loan Interest Rate Register — three KPI cards followed by the + * registers table (name, effective window, status, author, last modified). + */ +@Component({ + selector: 'mifosx-loan-interest-rate-register', + templateUrl: './loan-interest-rate-register.component.html', + styleUrls: ['./loan-interest-rate-register.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + DatePipe + ] +}) +export class LoanInterestRateRegisterComponent implements OnInit { + private readonly service = inject(RateRegisterService); + private readonly router = inject(Router); + + registers: RateRegister[] = []; + active?: RateRegister; + + ngOnInit(): void { + this.service.list().subscribe((rows) => { + this.registers = rows; + this.active = rows.find((r) => r.status === 'Active'); + }); + } + + edit(register: RateRegister): void { + this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } }); + } + + create(): void { + this.router.navigate(['/products/loan-interest-rate-register/edit']); + } + + statusChipClass(status: RateRegister['status']): string { + switch (status) { + case 'Active': + return 'chip chip--active'; + case 'Draft': + return 'chip chip--draft'; + case 'Expired': + return 'chip chip--expired'; + default: + return 'chip chip--scheduled'; + } + } + + activeWindow(): string { + if (!this.active) { + return '—'; + } + return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`; + } + + private formatShort(iso: string | null): string { + if (!iso) { + return '—'; + } + const d = new Date(iso); + return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' }); + } +} diff --git a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts new file mode 100644 index 000000000..d1ffe6e02 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts @@ -0,0 +1,52 @@ +/** + * 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/. + */ + +export type RegisterStatus = 'Active' | 'Draft' | 'Expired' | 'Scheduled'; + +export interface SlabColumn { + key: string; + label: string; + amountUpto: number | null; +} + +/** A single rate cell: number for a %, string for a formula ("1% + FD"), null for N.A. */ +export type RateCell = number | string | null; + +export interface RateChartRow { + srNo: number; + particulars: string; + isFormula?: boolean; + rates: RateCell[]; +} + +export type RiskTier = 'Poor' | 'Fair' | 'Good' | 'V. Good' | 'Excellent'; + +export interface CibilSlab { + from: number; + to: number; + tier: RiskTier; + type: 'Flat'; + adjustmentPct: number; +} + +export interface RateRegister { + id: number; + name: string; + effectiveFrom: string; + effectiveTo: string | null; + currency: string; + status: RegisterStatus; + createdBy: string; + lastModified: string; + note?: string; + slabs: SlabColumn[]; + rows: RateChartRow[]; + cibilSlabs: CibilSlab[]; + fallbackNoCibilSlab: number; + fallbackBelowMin: 'use-min-slab' | 'block'; +} diff --git a/src/app/products/loan-interest-rate-register/rate-register.service.ts b/src/app/products/loan-interest-rate-register/rate-register.service.ts new file mode 100644 index 000000000..69453daec --- /dev/null +++ b/src/app/products/loan-interest-rate-register/rate-register.service.ts @@ -0,0 +1,457 @@ +/** + * 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 { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model'; + +/** + * In-memory stand-in for the central Loan Interest Rate Register. + * + * Mirrors the Excel chart the bank's Board approves each quarter (34 products × 10 amount slabs). + * Backing API will land later; today the service only serves the demo data in memory so the + * screens can be driven end-to-end without a server. + */ +@Injectable({ providedIn: 'root' }) +export class RateRegisterService { + private readonly slabs: SlabColumn[] = [ + { key: 's1', label: 'Upto 10,000', amountUpto: 10000 }, + { key: 's2', label: 'Upto 25,000', amountUpto: 25000 }, + { key: 's3', label: 'Upto 1,00,000', amountUpto: 100000 }, + { key: 's4', label: 'Upto 2,00,000', amountUpto: 200000 }, + { key: 's5', label: 'Upto 5,00,000', amountUpto: 500000 }, + { key: 's6', label: 'Upto 10,00,000', amountUpto: 1000000 }, + { key: 's7', label: 'Upto 15,00,000', amountUpto: 1500000 }, + { key: 's8', label: 'Upto 20,00,000', amountUpto: 2000000 }, + { key: 's9', label: 'Upto 25,00,000', amountUpto: 2500000 }, + { key: 's10', label: '> 25,00,000', amountUpto: null } + ]; + + // Values taken from the bank's approved Interest Rate of Loans register. + private readonly rows: RateChartRow[] = [ + this.row(1, 'Consumer Loan', [ + 18, + 18, + 15, + 13, + null, + null, + null, + null, + null, + null + ]), + this.row( + 2, + 'Self Employment Loan', + [ + 18, + 18, + 15, + 13, + 12.5, + 12, + 11.5, + 11, + 10.5, + 10.5 + ] + ), + this.row( + 3, + 'Business Term Loan for running', + [ + 18, + 18, + 15, + 13, + 12.5, + 11, + 10.5, + 10, + 9.5, + 9.5 + ] + ), + this.row( + 4, + 'Cash Credit', + [ + 18, + 18, + 15, + 13, + 13, + 11, + 10.5, + 10, + 9.5, + 9.5 + ] + ), + this.row( + 5, + 'Limit (OD) on property — MSME', + [ + 18, + 18, + 15, + 13, + 13, + 11, + 10.5, + 10, + 9.5, + 9.5 + ] + ), + this.row( + 6, + 'Limit (OD) on property — Personal', + [ + 18, + 18, + 15, + 14, + 13.5, + 12, + 11.5, + 11, + 10.5, + 10.5 + ] + ), + this.row(7, 'House Purchase / Construction', [ + null, + null, + 15, + 12, + 12, + 9.25, + 8.75, + 8.25, + 8, + 8 + ]), + this.row(8, 'House Repairing / Renovation', [ + null, + null, + 15, + 14, + 14, + 13, + 12, + 11, + 10.5, + 10 + ]), + this.row( + 9, + 'Commercial Real Estate Loan', + [ + 18, + 18, + 15, + 13, + 12.5, + 12, + 11, + 10.5, + 10.5, + 10 + ] + ), + this.row(10, 'Loan against Jewellery', this.flat(10.8)), + this.row(11, 'Loan against Warehouse Receipt (A)', this.flat(13)), + this.row( + 12, + 'Loan against Warehouse Receipt (B)', + [ + 10.5, + 10.5, + 10.5, + 10.5, + 10.5, + 10.5, + 10, + 10, + 10, + 9 + ] + ), + this.row(13, 'Agriculture Loan', [ + null, + null, + 15, + 12, + 12, + 12, + 11.75, + 11.5, + 11.25, + 11 + ]), + this.row(14, 'Personal Loan (Unsecured)', [ + 18, + 18, + 16, + 15, + null, + null, + null, + null, + null, + null + ]), + this.row( + 15, + 'Personal Loan (Secured / Property)', + [ + 18, + 18, + 16, + 15, + 15, + 13, + 12, + 11.5, + 11, + 11 + ] + ), + this.row(16, 'Two Wheeler Loan', this.flat(13)), + this.row(17, 'Car Loan (4-Wheel Domestic)', [ + null, + null, + 10, + 9.5, + 9.5, + 8.75, + 8.5, + 8.35, + 8.25, + 8.25 + ]), + this.row(18, 'Old Car Loan (4-Wheel Domestic)', [ + null, + null, + 13, + 12.5, + 12.5, + 12, + 11.5, + 11, + 10.5, + 10.5 + ]), + this.row(19, 'Vehicle Loan (Commercial & Three-wheeler)', [ + null, + null, + 13, + 12, + 12, + 10.5, + 10, + 10, + 10, + 10 + ]), + this.row(20, 'Old Vehicle Loan (Commercial)', [ + null, + null, + 15, + 14, + 14, + 13.5, + 13.5, + 13, + 12.5, + 12.5 + ]), + this.row( + 21, + 'Machinery & Plant Loan', + [ + 18, + 18, + 15, + 13, + 12.5, + 12, + 11, + 10.5, + 10.5, + 10 + ] + ), + this.row( + 22, + 'Office Chamber / Equipment Loan', + [ + 18, + 18, + 15, + 13, + 12.5, + 12, + 11, + 10.5, + 10.5, + 10 + ] + ), + this.formulaRow(23, 'Loan against F.D.R.', [ + '1% + FD', + '1% + FD', + '1% + FD', + '1% + FD', + '1% + FD', + '1% + FD', + '0.75% + FD', + '0.50% + FD', + '0.50% + FD', + '0.50% + FD' + ]), + this.row(24, 'Loan against Daily Deposit & Gold', this.flat(10)), + this.formulaRow(25, 'Loan against Recurring Deposit', Array(10).fill('1% + RD')), + this.row(26, 'Loan against Staff Service Fund', this.flat(7)), + this.row(27, 'Over Draft A/c', this.flat(null)), + this.row(28, 'Loan on LIC Policy', this.flat(null)), + this.row(29, 'Loan on KVP / NSC', this.flat(null)), + this.row(30, 'Bill Discount', this.flat(null)), + this.row(31, 'Temporary Limit', this.flat(15)), + this.row(32, 'Bank Guarantee (commission)', this.flat(0.25)), + this.row(33, 'Sugam Vyapar Loan', this.flat(null)), + this.row(34, 'OD to Staff (upto 2 lakh)', this.flat(null)) + ]; + + private readonly cibilSlabs: CibilSlab[] = [ + { from: 0, to: 649, tier: 'Poor', type: 'Flat', adjustmentPct: 1.0 }, + { from: 650, to: 749, tier: 'Fair', type: 'Flat', adjustmentPct: 0.5 }, + { from: 750, to: 799, tier: 'Good', type: 'Flat', adjustmentPct: 0 }, + { from: 800, to: 850, tier: 'V. Good', type: 'Flat', adjustmentPct: -0.1 }, + { from: 851, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: -0.2 } + ]; + + private readonly registers: RateRegister[] = [ + { + id: 1001, + name: 'Q1 2027 Rates', + effectiveFrom: '2027-01-01', + effectiveTo: '2027-03-31', + currency: 'INR', + status: 'Draft', + createdBy: 'Sonali', + lastModified: '2026-10-01', + note: 'Scheduled · future effective', + slabs: this.slabs, + rows: this.rows, + cibilSlabs: this.cibilSlabs, + fallbackNoCibilSlab: 2, + fallbackBelowMin: 'block' + }, + { + id: 1002, + name: 'Interest Rate of Loans — Oct 2026', + effectiveFrom: '2026-10-01', + effectiveTo: '2026-12-31', + currency: 'INR', + status: 'Active', + createdBy: 'Admin', + lastModified: '2026-09-25', + note: 'Approved by Board on 25 Sep', + slabs: this.slabs, + rows: this.rows, + cibilSlabs: this.cibilSlabs, + fallbackNoCibilSlab: 2, + fallbackBelowMin: 'block' + }, + { + id: 1003, + name: 'Q3 2026 Rates', + effectiveFrom: '2026-07-01', + effectiveTo: '2026-09-30', + currency: 'INR', + status: 'Expired', + createdBy: 'Admin', + lastModified: '2026-06-15', + slabs: this.slabs, + rows: this.rows, + cibilSlabs: this.cibilSlabs, + fallbackNoCibilSlab: 2, + fallbackBelowMin: 'block' + }, + { + id: 1004, + name: 'Q2 2026 Rates', + effectiveFrom: '2026-04-01', + effectiveTo: '2026-06-30', + currency: 'INR', + status: 'Expired', + createdBy: 'Admin', + lastModified: '2026-03-10', + slabs: this.slabs, + rows: this.rows, + cibilSlabs: this.cibilSlabs, + fallbackNoCibilSlab: 2, + fallbackBelowMin: 'block' + }, + { + id: 1005, + name: 'Q1 2026 Rates', + effectiveFrom: '2026-01-01', + effectiveTo: '2026-03-31', + currency: 'INR', + status: 'Expired', + createdBy: 'Admin', + lastModified: '2025-12-20', + slabs: this.slabs, + rows: this.rows, + cibilSlabs: this.cibilSlabs, + fallbackNoCibilSlab: 2, + fallbackBelowMin: 'block' + } + ]; + + list(): Observable { + return of(this.registers); + } + + get(id: number): Observable { + return of(this.registers.find((r) => r.id === id)); + } + + /** The one shown by default on list → edit navigation. */ + getActiveOrFirstDraft(): RateRegister { + return ( + this.registers.find((r) => r.status === 'Draft') ?? + this.registers.find((r) => r.status === 'Active') ?? + this.registers[0] + ); + } + + getSlabColumns(): SlabColumn[] { + return this.slabs; + } + + getCibilSlabs(): CibilSlab[] { + return this.cibilSlabs; + } + + private row(srNo: number, particulars: string, rates: (number | null)[]): RateChartRow { + return { srNo, particulars, rates }; + } + + private formulaRow(srNo: number, particulars: string, rates: string[]): RateChartRow { + return { srNo, particulars, isFormula: true, rates }; + } + + private flat(value: number | null): (number | null)[] { + return Array(10).fill(value); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts index b35557303..ddb60f62b 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts @@ -58,7 +58,7 @@ export class LoanProductAdvanceLimitTenureStepComponent { readonly appropriationOrderOptions = [ { id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' }, - { id: 'penal_interest_principal_charges', label: 'Penal → Interest → Principal → Charges' } + { id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' } ]; readonly yesNoOptions = [ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html index 62b792538..e35ab7664 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html @@ -103,8 +103,8 @@ {{ 'labels.inputs.Start Date' | translate }} {{ 'labels.inputs.Close Date' | translate }} + +
Date: Wed, 7 Oct 2026 15:30:15 +0530 Subject: [PATCH 06/21] Add interest loan related UI --- .../cibil-adjustment.component.html | 206 ++----- .../cibil-adjustment.component.scss | 522 +++++++++------- .../cibil-adjustment.component.ts | 158 ++--- .../edit-register.component.html | 42 +- .../edit-register.component.scss | 94 +++ .../edit-register/edit-register.component.ts | 46 +- ...loan-interest-rate-register.component.html | 178 ++++-- ...loan-interest-rate-register.component.scss | 296 +++++++++ .../loan-interest-rate-register.component.ts | 120 +++- .../models/rate-register.model.ts | 7 +- .../preview-register.component.html | 181 ++++++ .../preview-register.component.scss | 362 +++++++++++ .../preview-register.component.ts | 210 +++++++ .../rate-register.service.ts | 583 +++++++++++++++--- src/app/products/products-routing.module.ts | 6 + src/app/products/products.service.ts | 32 +- src/assets/env.js | 10 +- src/environments/.env.ts | 4 +- 18 files changed, 2445 insertions(+), 612 deletions(-) create mode 100644 src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html create mode 100644 src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss create mode 100644 src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html index 92b80c9de..25a387ffa 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html @@ -7,6 +7,23 @@ -->
+
+ +
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+ +
+
CS
@@ -16,151 +33,64 @@
-
-
-
-
-
Credit score slabs
- -
- - - - - - - - - - - - - - - - - - - - - - - -
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }} - {{ slab.tier }} - {{ slab.type }} - {{ formatPct(slab.adjustmentPct) }} - ⋯
-
- -
-
-
Fallback behaviour
-
-
- - -
-
- - -
-
+
+
+
+
Credit score slabs
+
+ Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
+
-
-
-
-
Live preview
-
pick a scenario
-
-
-
-
- - + + + + + + + + + + + + + + + + + + + + + + + +
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }} + {{ slab.tier }} + {{ slab.type }} +
+ + %
-
- - -
-
- - -
-
- - -
- - -
-
RATE DERIVATION
-
- Chart cell — {{ productLabel(previewProductSrNo) }} - {{ - chartCell() !== null ? (chartCell() | number: '1.2-2') + '%' : 'N.A.' - }} -
-
- CIBIL {{ previewCibil }} ({{ cibilSlab()?.tier ?? '—' }}) - - {{ formatPct(cibilSlab()?.adjustmentPct ?? 0) }} - -
-
- Product floor / ceiling - no clamp -
-
-
- Final rate - {{ finalRate() }} -
-
- - - -
-
-
What changes if…
-
-
- CIBIL = 700 - {{ rateFor(700) }} -
-
- CIBIL = 870 - {{ rateFor(870) }} -
-
- No CIBIL fetched - {{ rateForFallback() }} -
-
-
-
- +
+ +
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss index 243151aa0..19f0c1489 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss @@ -9,11 +9,96 @@ .ca { background: #f8fafc; min-height: calc(100vh - 160px); - padding: 20px 32px; + padding: 24px 32px; display: flex; flex-direction: column; gap: 16px; + &__active-strip { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 10px; + font-size: 12px; + color: #065f46; + } + + &__active-dot { + color: #059669; + font-size: 14px; + line-height: 1; + } + + &__active-label { + font-size: 10px; + letter-spacing: 0.5px; + font-weight: 700; + color: #065f46; + } + + &__active-name { + font-weight: 600; + color: #064e3b; + } + + &__active-sub { + color: #047857; + } + + &__active-flag { + margin-left: auto; + font-size: 11px; + font-weight: 600; + color: #047857; + background: #d1fae5; + padding: 3px 10px; + border-radius: 999px; + } + + &__active-link { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #047857; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + &__readonly-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + background: #fef3c7; + border: 1px solid #fcd34d; + border-left: 4px solid #d97706; + border-radius: 10px; + color: #78350f; + + i { + font-size: 16px; + color: #d97706; + } + } + + &__readonly-title { + font-size: 13px; + font-weight: 700; + color: #78350f; + } + + &__readonly-sub { + margin-top: 2px; + font-size: 12px; + color: #92400e; + } + &__header { display: flex; align-items: center; @@ -21,20 +106,21 @@ padding: 20px 24px; background: white; border: 1px solid #e2e8f0; - border-radius: 10px; + border-radius: 12px; } &__header-icon { - width: 40px; - height: 40px; - border-radius: 9px; + width: 42px; + height: 42px; + border-radius: 10px; background: #dbeafe; color: #1d4ed8; display: flex; align-items: center; justify-content: center; - font-size: 15px; + font-size: 14px; font-weight: 700; + letter-spacing: 0.3px; } &__header-text { @@ -44,6 +130,7 @@ margin: 0; font-size: 20px; font-weight: 600; + color: #0f172a; } p { @@ -53,282 +140,184 @@ } } - &__body { - display: grid; - grid-template-columns: 1.3fr 1fr; - gap: 16px; - } - - &__left, - &__right { - display: flex; - flex-direction: column; - gap: 14px; - } - &__card { background: white; border: 1px solid #e2e8f0; - border-radius: 10px; + border-radius: 12px; overflow: hidden; } &__card-head { - padding: 12px 16px; + padding: 16px 20px; border-bottom: 1px solid #f1f5f9; display: flex; - align-items: center; - gap: 10px; - } - - &__card-title { - font-size: 14px; - font-weight: 600; - } - - &__card-hint { - margin-left: auto; - font-size: 11px; - color: #64748b; - } - - &__card-body { - padding: 14px 18px; - display: flex; - flex-direction: column; + align-items: flex-start; + justify-content: space-between; gap: 12px; } + &__card-title { + font-size: 15px; + font-weight: 600; + color: #0f172a; + } + + &__card-sub { + margin-top: 2px; + font-size: 12px; + color: #64748b; + } + &__dash-btn { - margin-left: auto; - height: 28px; - font-size: 11px; + height: 32px; + font-size: 12px; font-weight: 600; color: #1d4ed8; border: 1px dashed #1d4ed8; + padding: 0 14px; i { - margin-right: 3px; + margin-right: 6px; } } - &__table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - - thead tr { - background: #f8fafc; - color: #64748b; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.3px; - - th { - text-align: left; - padding: 9px 14px; - font-weight: 600; - } - } - - tbody tr { - border-top: 1px solid #f1f5f9; - - td { - padding: 11px 14px; - } - } - } - - &__row-alt { - background: #fafbfc; - } - &__num { text-align: right; } - &__dots { + &__col-ix { + width: 44px; text-align: center; color: #94a3b8; - cursor: pointer; + font-weight: 600; } - &__fallback-grid, - &__preview-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 14px; + &__col-del { + width: 48px; + text-align: center; } - &__field { + &__cell-muted { + color: #64748b; + } + + &__cell-readonly { + color: #0f172a; + font-variant-numeric: tabular-nums; + padding: 0 22px; + } + + &__footer { display: flex; - flex-direction: column; + justify-content: flex-end; + gap: 10px; + padding: 4px 2px; + } +} - label { - font-size: 11px; +/* ---- Editable grid ---------------------------------------------------- */ + +.ca__editgrid { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + thead tr { + background: #f8fafc; + + th { + text-align: left; + padding: 10px 16px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; color: #64748b; font-weight: 600; - letter-spacing: 0.3px; - margin-bottom: 4px; - } + border-bottom: 1px solid #e2e8f0; - input, - select { - height: 36px; - border: 1px solid #cbd5e1; - background: white; - border-radius: 6px; - padding: 0 12px; - font-size: 13px; - color: #0f172a; - font-family: inherit; - - &:focus { - outline: 2px solid #1d4ed8; - outline-offset: -1px; + &.ca__num { + text-align: right; } } } - &__derivation { - padding: 12px 14px; - background: #f8fafc; - border-radius: 8px; - } + tbody tr { + border-bottom: 1px solid #f1f5f9; + transition: background 0.12s ease; - &__derivation-label { - font-size: 11px; - color: #64748b; - font-weight: 600; - letter-spacing: 0.3px; - margin-bottom: 8px; - } + &:hover { + background: #f8fafc; - &__derivation-row { - display: flex; - justify-content: space-between; - font-size: 12px; - padding: 3px 0; - - &--muted { - color: #64748b; - } - } - - &__derivation-val { - font-weight: 600; - - &--pos { - color: #059669; + .ca__delete-btn { + opacity: 1; + } } - &--neg { - color: #dc2626; + &:last-child { + border-bottom: 0; + } + + td { + padding: 8px 12px; + vertical-align: middle; } } +} - &__divider { - height: 1px; - background: #e2e8f0; - margin: 8px 0; - } +/* Borderless cell input — gets a visible border on hover/focus. */ +.ca__ghost-input { + width: 100%; + height: 32px; + border: 1px solid transparent; + background: transparent; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + color: #0f172a; + font-family: inherit; + box-sizing: border-box; - &__final { - display: flex; - justify-content: space-between; - align-items: center; - - span:first-child { - font-size: 12px; - font-weight: 600; - } - } - - &__final-val { - font-size: 20px; - font-weight: 700; - color: #1d4ed8; - } - - &__scenarios { - display: flex; - flex-direction: column; - gap: 8px; - font-size: 12px; - color: #334155; - } - - &__scenario { - padding: 8px 10px; - background: #f8fafc; - border-radius: 6px; - display: flex; - justify-content: space-between; - - span:last-child { - font-weight: 600; - } - } - - &__scenario-val--pos { - color: #059669; - } - - &__scenario-val--warn { - color: #d97706; - } - - &__footer { + &:hover { + border-color: #e2e8f0; background: white; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 10px 16px; - display: flex; - align-items: center; - justify-content: space-between; } - &__footer-note { - font-size: 11px; - color: #64748b; + &:focus { + outline: none; + border-color: #1d4ed8; + background: white; + box-shadow: 0 0 0 3px rgb(29 78 216 / 10%); + } + + &--num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + /* Hide number input spinners for a cleaner look. */ + &[type='number']::-webkit-inner-spin-button, + &[type='number']::-webkit-outer-spin-button { + appearance: none; + margin: 0; + } + + &[type='number'] { + appearance: textfield; } } -.tier { - padding: 3px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - - &--poor { - background: #fee2e2; - color: #991b1b; - } - - &--fair { - background: #fef3c7; - color: #92400e; - } - - &--good { - background: #dbeafe; - color: #1e40af; - } - - &--vgood { - background: #dcfce7; - color: #166534; - } - - &--excellent { - background: #d1fae5; - color: #065f46; - } +/* Adjustment input — coloured by sign, with trailing % suffix. */ +.ca__adj-wrap { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + width: 100%; } -.adj { - font-weight: 600; +.ca__adj-input { + width: 72px; + font-weight: 700; &--negative { color: #059669; @@ -342,3 +331,84 @@ color: #dc2626; } } + +.ca__adj-suffix { + color: #94a3b8; + font-size: 12px; + font-weight: 600; +} + +/* Chip-shaped tier select — background matches the current tier. */ +.ca__tier-select { + appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 10px center; + padding: 4px 26px 4px 12px; + height: 28px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgb(29 78 216 / 15%); + } + + &--vpoor { + background-color: #fecaca; + color: #7f1d1d; + } + + &--poor { + background-color: #fee2e2; + color: #991b1b; + } + + &--fair { + background-color: #fef3c7; + color: #92400e; + } + + &--good { + background-color: #dbeafe; + color: #1e40af; + } + + &--excellent { + background-color: #d1fae5; + color: #065f46; + } +} + +/* Trash button — hidden until row hover. */ +.ca__delete-btn { + width: 28px; + height: 28px; + border: 1px solid transparent; + background: transparent; + color: #94a3b8; + border-radius: 6px; + font-size: 12px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: + opacity 0.12s ease, + color 0.12s ease, + border-color 0.12s ease, + background 0.12s ease; + + &:hover { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; + } + + &:focus-visible { + opacity: 1; + } +} diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts index 4a183cabf..244bca647 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts @@ -11,15 +11,13 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; import { RateRegisterService } from '../rate-register.service'; import { CibilSlab, RateRegister } from '../models/rate-register.model'; /** - * CIBIL/credit-score adjustment editor for a Rate Register — slab table, fallback behaviour - * and a live preview that resolves {product, loan amount, CIBIL} down to the final rate. + * CIBIL / credit-score slab editor. + * Score range, tier and type are fixed per slab; only the Adjustment % is editable. + * New slabs can be appended (and removed) via Add slab / row trash. */ @Component({ selector: 'mifosx-cibil-adjustment', @@ -28,10 +26,7 @@ import { CibilSlab, RateRegister } from '../models/rate-register.model'; imports: [ CommonModule, FormsModule, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule + MatButtonModule ] }) export class CibilAdjustmentComponent implements OnInit { @@ -40,123 +35,100 @@ export class CibilAdjustmentComponent implements OnInit { private readonly router = inject(Router); register!: RateRegister; - - // Live preview inputs. - previewProductSrNo = 7; // House Purchase / Construction - previewAmount = 2500000; - previewCibil = 820; - previewTenureMonths = 120; + active?: RateRegister; ngOnInit(): void { const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + this.active = this.service.getActive(); }); } + isActiveRegister(): boolean { + return !!this.active && this.active.id === this.register?.id; + } + + viewActive(): void { + if (this.active) { + this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.active.id } }); + } + } + + /** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */ + get canEdit(): boolean { + return this.register?.status === 'Draft'; + } + + readOnlyReason(): string { + switch (this.register?.status) { + case 'Pending Approval': + return 'This version has been submitted and is awaiting checker approval. No further edits allowed.'; + case 'Active': + return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes.'; + case 'Expired': + return 'This is an inactive historical version. Read-only.'; + default: + return 'Read-only.'; + } + } + tierClass(tier: CibilSlab['tier']): string { const map: Record = { + 'V. Poor': 'tier tier--vpoor', Poor: 'tier tier--poor', Fair: 'tier tier--fair', Good: 'tier tier--good', - 'V. Good': 'tier tier--vgood', Excellent: 'tier tier--excellent' }; return map[tier] ?? 'tier'; } - adjustmentClass(pct: number): string { + /** Classes to colour the adjustment number by sign/magnitude. */ + adjustInputClass(pct: number): string { + const base = 'ca__ghost-input ca__ghost-input--num ca__adj-input'; if (pct < 0) { - return 'adj adj--negative'; + return `${base} ca__adj-input--negative`; + } + if (pct >= 1) { + return `${base} ca__adj-input--high`; } if (pct > 0) { - return pct >= 1 ? 'adj adj--high' : 'adj adj--positive'; + return `${base} ca__adj-input--positive`; } - return 'adj'; + return base; } - formatPct(pct: number): string { - if (pct === 0) { - return '0.00%'; + /** Append a new slab continuing from the current highest "score to". */ + addSlab(): void { + if (!this.canEdit) { + return; } - const sign = pct > 0 ? '+' : '−'; - return `${sign}${Math.abs(pct).toFixed(2)}%`; + const lastTo = this.register.cibilSlabs.length ? Math.max(...this.register.cibilSlabs.map((s) => s.to)) : 0; + const from = Math.min(lastTo + 1, 900); + this.register.cibilSlabs.push({ + from, + to: Math.min(from + 50, 900), + tier: 'Fair', + type: 'Flat', + adjustmentPct: 0 + }); } - /** Looks up the chart cell for the currently previewed scenario. */ - private chartRateFor(productSrNo: number, amount: number): number | null { - const row = this.register.rows.find((r) => r.srNo === productSrNo); - if (!row) { - return null; + deleteSlab(index: number): void { + if (!this.canEdit) { + return; } - for (let i = 0; i < this.register.slabs.length; i++) { - const slab = this.register.slabs[i]; - const upto = slab.amountUpto; - if (upto === null || amount <= upto) { - const cell = row.rates[i]; - if (cell === null) { - return null; - } - if (typeof cell === 'string') { - // Formula cell — in a real derive we'd resolve against the pledged deposit's rate. - return null; - } - return cell; - } - } - return null; + this.register.cibilSlabs.splice(index, 1); } - private cibilAdjustmentFor(score: number): CibilSlab | null { - return this.register.cibilSlabs.find((s) => score >= s.from && score <= s.to) ?? null; - } - - chartCell(): number | null { - return this.chartRateFor(this.previewProductSrNo, this.previewAmount); - } - - cibilSlab(): CibilSlab | null { - return this.cibilAdjustmentFor(this.previewCibil); - } - - finalRate(): string { - const base = this.chartCell(); - const slab = this.cibilSlab(); - if (base === null) { - return 'N.A.'; - } - const adjustment = slab?.adjustmentPct ?? 0; - return `${(base + adjustment).toFixed(2)}% p.a.`; - } - - productLabel(srNo: number): string { - return this.register.rows.find((r) => r.srNo === srNo)?.particulars ?? '—'; - } - - rateFor(cibil: number): string { - const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount); - if (base === null) { - return 'N.A.'; - } - const slab = this.cibilAdjustmentFor(cibil); - return `${(base + (slab?.adjustmentPct ?? 0)).toFixed(2)}%`; - } - - rateForFallback(): string { - const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount); - if (base === null) { - return 'N.A.'; - } - const fallback = this.register.cibilSlabs[this.register.fallbackNoCibilSlab - 1]; - return `${(base + (fallback?.adjustmentPct ?? 0)).toFixed(2)}%`; - } - - backToRegister(): void { + /** Edits are kept in the in-memory service (the register reference is shared). */ + save(): void { this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } }); } - save(): void { - this.backToRegister(); + backToRegister(): void { + this.save(); } } diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html index 02d72204d..8db7e5e86 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -7,27 +7,44 @@ -->
+
+ ● + CURRENT ACTIVE + {{ active.name }} + · effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }} + You are viewing the Active register + View active → +
+ +
+ +
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+
- +
- +
- +
-
- +
@@ -35,9 +52,11 @@
Rate chart grid
- {{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit + {{ rows.length }} products × {{ slabs.length }} amount slabs + · click any cell to edit + · read-only view
-
+
@@ -70,7 +89,8 @@ [class.er__cell--na]="isNa(cell)" [class.er__cell--formula]="isFormulaRow(row)" [class.er__cell--editing]="isEditing(i, j)" - (click)="!isEditing(i, j) && startEdit(i, j, cell)" + [class.er__cell--readonly]="!canEdit" + (click)="canEdit && !isEditing(i, j) && startEdit(i, j, cell)" >
CIBIL / credit score adjustment
- {{ register.cibilSlabs.length }} slabs · +1.00% (poor) to −0.20% (excellent) · applied on top of chart rate + {{ register.cibilSlabs.length }} slabs · +2.00% (very poor) to base (above 700) · applied on top of chart rate
Edit →
@@ -227,7 +230,7 @@
{{ fdTypeShort(r.fdType) }} {{ r.interestPostingDate }}{{ r.interestPeriodFrom || '—' }} → {{ r.interestPostingDate }} ₹{{ r.depositAmount | number: '1.2-2' }} {{ r.interestRate | number: '1.2-2' }} {{ r.maturityDate || '—' }}
+ + + + + + + + + + @for (g of outsideApprovalGroups; track g.productName) { + + + + + + + } @empty { + + + + } + +
ProductSchemeDepositsInterest
+ {{ g.productName }} + + {{ fdTypeShort(g.fdType) }} + {{ g.accounts }}₹{{ g.interest | number: '1.2-2' }}
+ No fixed deposit interest was posted outside this report in this period. +
+
+ + @if (outsideApprovalRows.length > 0) { +
+ + + + + + + + + + + + + + @for (r of outsideApprovalRows; track r.transactionId) { + + + + + + + + + + } + + + + + + + + +
Account noCustomerProductSchemeInterest forInterestPosted at
{{ r.accountNo }}{{ r.clientName }}{{ r.productName }} + {{ fdTypeShort(r.fdType) }} + {{ r.interestFor }}₹{{ r.interestAmount | number: '1.2-2' }}{{ r.postedAt | date: 'dd MMM yyyy, HH:mm' }}
Total + ₹{{ outsideApprovalTotal | number: '1.2-2' }} +
+
+ } + } +
+ } +

Reports{{ postingDate ? ' for ' + postingDate : '' }}

@@ -347,6 +473,7 @@

{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }} {{ fdTypeShort(selected.fdType) }} + {{ selected.postingPeriodLabel }} {{ selected.status }}

{{ fdTypeLabel(selected.fdType) }}
@@ -384,7 +511,7 @@ {{ l.accountNo }} {{ l.clientName }} - {{ l.interestPostingDate }} + {{ l.interestPeriodFrom || '—' }} → {{ l.interestPostingDate }} ₹{{ l.depositAmount | number: '1.2-2' }} {{ l.interestRate | number: '1.2-2' }} {{ l.maturityDate || '—' }} diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts index 28d12d189..d77e345fb 100644 --- a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts @@ -18,6 +18,7 @@ import { FdInterestReport, FdInterestReportDetail, FdInterestPostingReportService, + FdOutsideApprovalRow, FdReportScope, FdType } from './fd-interest-posting-report.service'; @@ -478,6 +479,61 @@ export class FdInterestPostingReportComponent implements OnInit { ); } + // ── Outside approval: deposit interest posted without this report, chiefly at maturity ── + + showOutsideApproval = false; + outsideApprovalLoading = false; + outsideApprovalRows: FdOutsideApprovalRow[] = []; + outsideApprovalFrom = ''; + outsideApprovalTo = ''; + + openOutsideApproval(): void { + this.showOutsideApproval = true; + if (!this.outsideApprovalTo) { + this.outsideApprovalTo = this.businessDateIso(); + this.outsideApprovalFrom = `${this.outsideApprovalTo.slice(0, 7)}-01`; + } + this.loadOutsideApproval(); + } + + loadOutsideApproval(): void { + this.outsideApprovalLoading = true; + this.errorMessage = ''; + this.reportService + .outsideApproval(this.outsideApprovalFrom || undefined, this.outsideApprovalTo || undefined, undefined, this.tab) + .subscribe({ + next: (rows) => { + this.outsideApprovalRows = rows ?? []; + this.outsideApprovalLoading = false; + }, + error: (err) => { + this.outsideApprovalLoading = false; + this.outsideApprovalRows = []; + this.errorMessage = this.messageOf(err, 'Could not load deposit interest posted outside this report.'); + } + }); + } + + closeOutsideApproval(): void { + this.showOutsideApproval = false; + } + + get outsideApprovalTotal(): number { + return this.outsideApprovalRows.reduce((sum, r) => sum + Number(r.interestAmount || 0), 0); + } + + /** Totals by product and scheme, so a branch can see at a glance where its unapproved interest sits. */ + get outsideApprovalGroups(): { productName: string; fdType: string; accounts: number; interest: number }[] { + const map = new Map(); + this.outsideApprovalRows.forEach((r) => { + const g = map.get(r.productName) ?? { productName: r.productName, fdType: r.fdType, accounts: 0, interest: 0 }; + g.accounts += 1; + g.interest += Number(r.interestAmount || 0); + map.set(r.productName, g); + }); + return [...map.values()]; + } + private businessDateIso(): string { const businessDate = this.settingsService.businessDate as any; const parsed = businessDate ? new Date(businessDate) : new Date(); diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts b/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts index 85d7a4b91..72d12f397 100644 --- a/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts @@ -37,6 +37,8 @@ export interface FdInterestReport { approvedOnBehalf: boolean; postedAt: string | null; failureReason: string | null; + /** The product's posting cycle in words — Monthly, Quarterly, Half-yearly, Yearly. */ + postingPeriodLabel: string; /** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */ canAct: boolean; blockedReason: string | null; @@ -49,6 +51,12 @@ export interface FdInterestReportLine { fdAccountId: number; accountNo: string; clientName: string; + /** + * The day after the deposit's last paid period ended, or its activation date for a first-ever + * posting — null for a line posted before this was tracked. Paired with interestPostingDate, it is + * the span the interest was earned over. + */ + interestPeriodFrom: string | null; /** * The period this interest is for, as the interest engine dated it — not the day the branch closes. * A deposit owed two unpaid periods appears on two lines, one per date. @@ -103,6 +111,28 @@ export interface FdInterestSummaryRow { approvedOnBehalf: boolean; approvedAt: string | null; postedAt: string | null; + postingPeriodLabel: string; +} + +/** + * One fixed deposit interest transaction that reached the customer's account without this report's + * approval — chiefly a deposit that matured before its own posting cycle completed, whose last period + * is settled directly by the maturity job. View only, for visibility. + */ +export interface FdOutsideApprovalRow { + transactionId: number; + fdAccountId: number; + accountNo: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + fdType: FdType; + interestFor: string; + interestAmount: number; + /** When the posting actually reached the account — not the period it is for. */ + postedAt: string; } export interface FdInterestPreviewRow { @@ -114,6 +144,8 @@ export interface FdInterestPreviewRow { productId: number; productName: string; fdType: FdType; + /** The day after the deposit's last paid period ended, or its activation date for the first one. */ + interestPeriodFrom: string | null; /** The period this interest belongs to, as the engine dated it. */ interestPostingDate: string; depositAmount: number | null; @@ -230,4 +262,19 @@ export class FdInterestPostingReportService { if (productId) params = params.set('productId', String(productId)); return this.http.get(`${this.base}/summary`, { params }); } + + /** Deposit interest posted without this report's approval — a deposit maturing mid-cycle, chiefly. */ + outsideApproval( + fromDate?: string, + toDate?: string, + officeId?: number, + scope?: FdReportScope + ): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/outside-approval`, { params }); + } } From d61584f291dd89c31b3bbf91288f9e0593935ba5 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Thu, 8 Oct 2026 17:22:59 +0530 Subject: [PATCH 09/21] feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register - Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account - Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts - Add rate-register-diff component to render before/after changes for checker approval - Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs - Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping) - Simplify preview-register component, moving diff rendering into the shared diff component - Add glAccountId field to RateChartRow model Co-Authored-By: Claude Sonnet 5 --- .../cibil-adjustment.component.ts | 7 +- .../edit-register.component.html | 48 +- .../edit-register.component.scss | 73 ++ .../edit-register/edit-register.component.ts | 96 ++- .../gl-picker/gl-picker.component.ts | 135 +++ ...loan-interest-rate-register.component.html | 8 +- ...loan-interest-rate-register.component.scss | 16 + .../loan-interest-rate-register.component.ts | 15 +- .../models/rate-register.model.ts | 8 + .../preview-register.component.html | 122 +-- .../preview-register.component.scss | 177 ---- .../preview-register.component.ts | 133 +-- .../rate-register-diff.util.ts | 48 ++ .../rate-register-diff.component.html | 118 +++ .../rate-register-diff.component.scss | 206 +++++ .../rate-register-diff.component.ts | 121 +++ .../rate-register.service.ts | 766 +++++++----------- src/app/products/products.service.ts | 22 + .../checker-inbox/checker-inbox.component.ts | 5 +- src/app/tasks/loan-rate-register-diff.util.ts | 169 ++++ .../view-checker-inbox.component.html | 26 +- .../view-checker-inbox.component.scss | 5 + .../view-checker-inbox.component.ts | 91 ++- src/environments/.env.ts | 4 +- 24 files changed, 1510 insertions(+), 909 deletions(-) create mode 100644 src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff.util.ts create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss create mode 100644 src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts create mode 100644 src/app/tasks/loan-rate-register-diff.util.ts diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts index 244bca647..701b5667c 100644 --- a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts @@ -41,7 +41,12 @@ export class CibilAdjustmentComponent implements OnInit { const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { - this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; this.active = this.service.getActive(); }); } diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html index 8db7e5e86..0ec403f4b 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -55,6 +55,9 @@ {{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit · read-only view + · {{ unmappedCount() }} without GL — map every product's GL before submitting
+
+
Add product
+
+
+ + +
+
+ + +
+ + +
+
{{ addProductError }}
+
@@ -82,6 +101,25 @@ diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss index 0756a126f..13cad796d 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss @@ -424,6 +424,22 @@ } } + &__gl-tag { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + } + &__history-foot { padding: 10px 20px; border-top: 1px solid #f1f5f9; diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts index 2f4265b80..2fc9f859b 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts @@ -38,9 +38,15 @@ export class LoanInterestRateRegisterComponent implements OnInit { active?: RateRegister; ngOnInit(): void { + // Load the GL list once so mapped particulars show a readable label instead of "GL #id". + this.service.getGlOptions().subscribe(); this.reload(); } + glLabel(id: number | null | undefined): string | null { + return this.service.glLabel(id); + } + private reload(): void { this.service.list().subscribe((rows) => { this.registers = rows; @@ -168,10 +174,11 @@ export class LoanInterestRateRegisterComponent implements OnInit { /** Scaffold the very first version — used only when the register has no versions at all. */ createFirstVersion(): void { - const draft = this.service.createFirstVersion(); - this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); - this.reload(); - this.edit(draft); + this.service.createFirstVersion().subscribe((draft) => { + this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); + this.reload(); + this.edit(draft); + }); } /** Open the Compare screen for a pending register (checker view). */ diff --git a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts index 943cc6029..8f25e359f 100644 --- a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts +++ b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts @@ -21,9 +21,17 @@ export interface RateChartRow { srNo: number; particulars: string; isFormula?: boolean; + /** GL account this particular is mapped to. null/undefined = "GL not mapped". */ + glAccountId?: number | null; rates: RateCell[]; } +/** One selectable GL account for the particular-to-GL mapping. */ +export interface GlOption { + id: number; + label: string; +} + export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent'; export interface CibilSlab { diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html index a7262b2a9..8f258ebdf 100644 --- a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html @@ -48,121 +48,23 @@ - -
-
-
-
Rate chart — inline diff
-
Changed cells highlighted · unchanged cells show single value
-
- - Show only changed rows - -
- -
- -
No changes detected
-
- Draft is identical to the current Active register. Edit the Draft first to see diffs here. -
-
- -
-
{{ row.srNo }}{{ row.particulars }} +
+ {{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }} +
+
{{ canEdit ? 'Cancel' : 'Back to list' }} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss index 78c8ccc5b..476337e6e 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss @@ -70,6 +70,79 @@ } } + &__gl { + margin: 3px 0 0 30px; + } + + &__gl-tag { + display: inline-block; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + white-space: nowrap; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + + &--editable { + cursor: pointer; + + &:hover { + outline: 1px solid #94a3b8; + } + } + } + + &__gl-select { + display: block; + margin: 4px 0 0 30px; + max-width: 340px; + } + + &__gl-warn { + color: #b45309; + font-weight: 600; + } + + &__add-panel { + padding: 14px 18px; + border-bottom: 1px solid #f1f5f9; + background: #f8fafc; + } + + &__add-title { + font-size: 13px; + font-weight: 600; + color: #0f172a; + margin-bottom: 10px; + } + + &__add-fields { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + + .er__field { + min-width: 240px; + } + + .er__field--gl { + min-width: 320px; + } + } + + &__add-error { + margin-top: 8px; + font-size: 12px; + color: #b91c1c; + } + &__readonly-banner { display: flex; align-items: center; diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts index 329cdd8f7..95472abde 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts @@ -6,7 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -18,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { RateRegisterService } from '../rate-register.service'; -import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlPickerComponent } from '../gl-picker/gl-picker.component'; /** * Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10 @@ -39,10 +41,13 @@ import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate MatSelectModule, MatIconModule, MatDatepickerModule, - MatNativeDateModule + MatNativeDateModule, + GlPickerComponent ] }) -export class EditRegisterComponent implements OnInit { +export class EditRegisterComponent implements OnInit, OnDestroy { + private readonly snack = inject(MatSnackBar); + private readonly service = inject(RateRegisterService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -58,11 +63,42 @@ export class EditRegisterComponent implements OnInit { @ViewChild('cellInput') cellInput?: ElementRef; + /** Leaving the page keeps the draft: edits are saved so a reload or a trip to Compare does not lose them. */ + ngOnDestroy(): void { + if (this.canEdit) { + this.service.saveDrafts(); + } + } + + saveDraft(): void { + this.service.saveDrafts(); + this.snack.open('Draft saved', 'OK', { duration: 2000 }); + } + + /** Posting GL accounts for the particular → GL mapping. */ + glOptions: GlOption[] = []; + + /** "Add product" panel state. A new product needs a name and a GL before it can be added. */ + addingProduct = false; + newProductName = ''; + newProductGl: number | null = null; + addProductError = ''; + + /** Index of the row whose GL is being changed inline, or null. */ + glEditRow: number | null = null; + ngOnInit(): void { + this.service.getGlOptions().subscribe((options) => (this.glOptions = options)); const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { - this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + // e.g. a reload on an unsaved local draft: nothing to edit, go back to the list. + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; this.active = this.service.getActive(); this.slabs = this.register.slabs; this.rows = this.register.rows; @@ -173,22 +209,66 @@ export class EditRegisterComponent implements OnInit { this.editing = null; } - /** Append a new empty product row at the bottom of the grid. */ + glLabel(row: RateChartRow): string | null { + return this.service.glLabel(row.glAccountId); + } + + unmappedCount(): number { + return this.rows.filter((r) => r.glAccountId == null).length; + } + + /** Submit is blocked until every product has a GL. */ + get canSubmit(): boolean { + return this.unmappedCount() === 0; + } + + /** Opens the Add product panel. */ addProduct(): void { if (!this.canEdit) { return; } - const name = window.prompt('New product name', 'New product'); + this.addingProduct = true; + this.newProductName = ''; + this.newProductGl = null; + this.addProductError = ''; + } + + cancelAddProduct(): void { + this.addingProduct = false; + this.addProductError = ''; + } + + /** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */ + confirmAddProduct(): void { + const name = this.newProductName.trim(); if (!name) { + this.addProductError = 'Enter the product name.'; + return; + } + if (this.rows.some((r) => r.particulars.trim().toLowerCase() === name.toLowerCase())) { + this.addProductError = 'A product with this name already exists.'; + return; + } + if (this.newProductGl == null) { + this.addProductError = 'Select the GL account for this product.'; return; } const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1; this.rows.push({ srNo: next, - particulars: name.trim(), + particulars: name, + glAccountId: this.newProductGl, rates: Array(this.slabs.length).fill(null), isFormula: false }); + this.cancelAddProduct(); + } + + /** Toggle the inline GL selector on an existing row (Draft only). */ + editGl(rowIdx: number): void { + if (this.canEdit) { + this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx; + } } /** Append a new amount slab column; every existing row grows by one blank cell. */ diff --git a/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts new file mode 100644 index 000000000..c58959fb2 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts @@ -0,0 +1,135 @@ +/** + * 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 { + AfterViewInit, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelect, MatSelectModule } from '@angular/material/select'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { Subject, takeUntil } from 'rxjs'; +import { GlOption } from '../models/rate-register.model'; + +/** + * Compact searchable GL account dropdown. Typing in the search box filters by GL code or name. + * Emits the chosen GL id; with allowClear a "GL not mapped" entry emits null. + */ +@Component({ + selector: 'mifosx-gl-picker', + imports: [ + CommonModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + NgxMatSelectSearchModule + ], + template: ` + + + + + + GL not mapped + {{ o.label }} + + + `, + styles: [ + ` + .gl-picker { + width: 100%; + min-width: 260px; + font-size: 12px; + } + ` + ] +}) +export class GlPickerComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { + @Input() options: GlOption[] = []; + @Input() value: number | null = null; + @Input() placeholder = 'Select GL account'; + @Input() allowClear = false; + /** Open the dropdown as soon as it is rendered (used for inline row editing). */ + @Input() openOnInit = false; + + @Output() valueChange = new EventEmitter(); + /** Fired when the dropdown panel closes, whether or not a value was picked. */ + @Output() closed = new EventEmitter(); + + @ViewChild(MatSelect) private select?: MatSelect; + + filterCtrl = new UntypedFormControl(''); + filtered: GlOption[] = []; + + private readonly destroy$ = new Subject(); + + ngOnInit(): void { + this.filterCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => this.applyFilter()); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['options'] || changes['value']) { + this.applyFilter(); + } + } + + ngAfterViewInit(): void { + if (this.openOnInit) { + setTimeout(() => this.select?.open()); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onOpenedChange(opened: boolean): void { + if (opened) { + this.filterCtrl.setValue(''); + } else { + this.closed.emit(); + } + } + + /** Filter by code or name; the currently selected option always stays in the list so its label shows. */ + private applyFilter(): void { + const q = ((this.filterCtrl.value as string) ?? '').trim().toLowerCase(); + const all = this.options ?? []; + if (!q) { + this.filtered = all.slice(); + return; + } + const matches = all.filter((o) => o.label.toLowerCase().includes(q)); + const selected = all.find((o) => o.id === this.value); + this.filtered = selected && !matches.includes(selected) ? [ + selected, + ...matches + ] : matches; + } +} diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html index ab378875c..dd57f3b14 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -54,9 +54,8 @@
No rate register versions yet
- Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's - approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before - submitting for approval. + Create the first version to publish rates for all loan products. It starts with the standard loan types (rates + blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
{{ row.srNo }}{{ row.particulars }} + {{ + row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId) + }} {{ display(cell) }}
- - - - - - - - - - - - -
Particulars{{ s.label }}
- {{ row.srNo }}{{ row.particulars }} - - - {{ display(baselineCell(row.srNo, j)) }} - → - {{ display(draftCell(row.srNo, j)) }} - - {{ display(draftCell(row.srNo, j)) }} -
-
-
- - -
-
-
-
CIBIL slabs — inline diff
-
Changed fields shown as OLD → NEW · unchanged fields render once
-
-
- - - - - - - - - - - - - - - - - - - -
#Score fromScore toTierAdjustment
{{ i + 1 }} - - {{ baselineCibil[i]?.from ?? '—' }} - → - {{ slab.from }} - - {{ slab.from }} - - - {{ baselineCibil[i]?.to ?? '—' }} - → - {{ slab.to }} - - {{ slab.to }} - - - {{ baselineCibil[i]?.tier ?? '—' }} - → - {{ slab.tier }} - - {{ slab.tier }} - - - {{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }} - → - {{ formatPct(slab.adjustmentPct) }} - - {{ formatPct(slab.adjustmentPct) }} -
-
+
@@ -617,80 +622,89 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + +
+ +
+
Core Reports
+ + + + + + + +
+ + +
+
Operations & Interest
+ + + + + + + +
+ + +
+
Compliance & Registers
+ + + + + + + + +
+
- - - - - - - - + +
+
+
Day Operations
+ + + +
+
+
Verification & Reports
+ + +
+
- - - - - - - - - - - - - - - - + + +
+ +
+
Clerk Desk
+ + + + +
+ + +
+
Vouchers
+ + + + +
+ + +
+
Counter & Reports
+ + + +
+
- - - - - - - - - - - - - - + + +
+ +
+
Cash & Counter
+ + + + +
+ + +
+
Transfers & Settlement
+ + + + + +
+ + +
+
Reports & Audit
+ + + + +
+
-
+
Accounts
- - - - - - - - - - + +
+ +
+
CTS Clearing
+ + + +
+ + +
+
Settlement & Recon
+ + + +
+ + +
+
NACH & Direct
+ + + +
+
-
+
Payroll
@@ -1573,23 +1632,33 @@
- - - - - - - + +
+ +
+
KYC Operations
+ + +
+ + +
+
Reports & Screening
+ + + +
+
+
+
Schemes & Social
+ + + + +
+ + +
+
Institutional & Loans
+ + + + +
+
- - - - - - - + +
+ +
+
Payments
+ + + +
+ + +
+
Cards & Mandates
+ + + +
+
diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 1dc00244e..c752a9d22 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -266,7 +266,7 @@ $color: #e2e4ec; max-width: 100%; overflow-x: hidden; box-sizing: border-box; - padding: 16px 28px; + padding: 10px 18px; } } @@ -282,21 +282,15 @@ $gray-700: #334155; /* ============================================================ Main header (top 2-row navigation) -------------------------------------------------------------- - Redesigned 2026 — clean, modern enterprise SaaS feel: - • Both rows live on the same white surface separated only by a - hairline; r2 has a barely-there blue-50 wash so frequent vs - secondary menus read at a glance without dark/light contrast. - • Items are now equal-width "tabs" with NO vertical dividers - between them (the old cell borders made it look spreadsheet-y). - • Hover / active = a 3 px brand-coloured TOP bar + brand text + - coloured icon. Reads instantly without colour-flooding the row. - • Icons larger (12.5 px) and full-opacity always — easier to scan - during demos. Caret is subtle and rotates on open. + Compact, modern enterprise SaaS navigation: + • Height reduced to 32px per row (64px total) to save screen space. + • Smooth horizontal scrolling prevents menu items from getting cut off. + • Compact typography, icons and paddings fit all banking modules cleanly. ============================================================ */ .main-header { background: #fff; - border-bottom: 2.5px solid $brand; - box-shadow: 0 1px 4px rgb(0 0 0 / 7%); + border-bottom: 2px solid $brand; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); position: sticky; /* Sit directly under the sticky toolbar (32px tall) so both stay visible. */ @@ -306,19 +300,19 @@ $gray-700: #334155; } .nav-row { - height: 44px; + height: 32px; display: flex; align-items: stretch; - overflow: visible; + overflow: auto visible; position: relative; - - /* Outer frame; vertical dividers between cells are drawn as `border-right` - on each `.ni` below. Switched away from the equal-column grid so each - cell can size to its own label — long names (General Ledger, Documents - Hub) no longer overflow and short ones (Approvals, HRM) no longer waste - space. */ border: 1px solid $gray-200; background: #fff; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } } .r2.nav-row { @@ -334,25 +328,17 @@ $gray-700: #334155; background: #f8fafc; } -// ===== Nav Item — modern tab style ===== -// Typography spec — same family / size / weight / color for BOTH rows. +// ===== Nav Item — compact modern tab style ===== .ni { position: relative; display: inline-flex; align-items: center; justify-content: center; - gap: 7px; + gap: 2.5px; height: 100%; - - /* Each cell sizes to its own label (min-width: max-content) and grows - to share any leftover row width. Short labels stay compact, long ones - get the room they need — no overlap, no wasted gap. */ flex: 1 0 auto; - min-width: max-content; - padding: 0 14px; - - /* Vertical divider between adjacent cells; outer frame is the row's - own border, and the last cell drops its right border below. */ + min-width: 0; + padding: 0 4px; border-right: 1px solid $gray-200; cursor: pointer; white-space: nowrap; @@ -360,11 +346,11 @@ $gray-700: #334155; 'IBM Plex Sans', -apple-system, sans-serif; - font-size: 12px; + font-size: 10px; font-weight: 500; color: #0a1a2e; - line-height: 1.3; - letter-spacing: 0.01em; + line-height: 1.2; + letter-spacing: -0.015em; text-decoration: none; user-select: none; transition: @@ -373,17 +359,15 @@ $gray-700: #334155; box-shadow 0.15s ease; border-radius: 0; - /* 3px accent strip that fills on hover / active (uses ::before so we - don't reflow the row when toggling). */ &::before { content: ''; position: absolute; - left: 4px; - right: 4px; + left: 2px; + right: 2px; top: 0; - height: 3px; + height: 2px; background: transparent; - border-radius: 0 0 3px 3px; + border-radius: 0 0 2px 2px; transition: background 0.15s ease; } @@ -410,17 +394,19 @@ $gray-700: #334155; outline-offset: -2px; } - /* Last cell in a row sits flush against the row's own right edge, so - drop its divider to avoid a double 2 px line. */ &:last-child { border-right: none; } + + .ni-short { + display: none; + } } .ni-ico { - width: 12px; - height: 12px; - font-size: 12px; + width: 9.5px; + height: 9.5px; + font-size: 9.5px; color: $gray-500; flex-shrink: 0; transition: color 0.15s ease; @@ -432,11 +418,12 @@ $gray-700: #334155; } .ni-caret { - width: 7px; - height: 7px; - font-size: 7px; + width: 5px; + height: 5px; + font-size: 5px; color: $gray-400; flex-shrink: 0; + margin-left: 0.5px; transition: transform 0.18s ease, color 0.18s ease; @@ -447,17 +434,89 @@ $gray-700: #334155; } } -@media (width <= 1200px) { +@media (width >= 1400px) { .ni { - padding: 0 8px; - gap: 5px; + padding: 0 6px; + gap: 3.5px; + font-size: 10.8px; + letter-spacing: 0; + } + + .ni-ico { + width: 10px; + height: 10px; + font-size: 10px; + } + + .ni-caret { + width: 5.5px; + height: 5.5px; + font-size: 5.5px; + margin-left: 1px; } } -@media (width <= 980px) { +@media (width <= 1200px) { .ni { + padding: 0 3px; + gap: 2px; + font-size: 9.8px; + letter-spacing: -0.02em; + } + + .ni-ico { + width: 9px; + height: 9px; + font-size: 9px; + } + + .ni-caret { + width: 4.5px; + height: 4.5px; + font-size: 4.5px; + } +} + +@media (width <= 1080px) { + .ni { + padding: 0 2.5px; + gap: 1.5px; + font-size: 9.4px; + letter-spacing: -0.025em; + + .ni-full { + display: none; + } + + .ni-short { + display: inline; + } + } + + .ni-ico { + width: 8.5px; + height: 8.5px; + font-size: 8.5px; + } + + .ni-caret { + width: 4px; + height: 4px; + font-size: 4px; + } +} + +@media (width <= 880px) { + .nav-row { + overflow-x: auto; + scrollbar-width: thin; + } + + .ni { + flex: 0 0 auto; padding: 0 6px; - gap: 4px; + gap: 3px; + font-size: 10px; } } @@ -699,21 +758,29 @@ $gray-700: #334155; // ===== Nav Dropdown Menus ===== ::ng-deep .nav-dropdown { - border-radius: 8px !important; + border-radius: 6px !important; border: 1px solid $gray-200 !important; - box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important; - min-width: 180px !important; - padding: 4px 0 !important; - margin-top: 2px !important; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important; + min-width: 170px !important; + padding: 3px 0 !important; + margin-top: 1px !important; .mat-mdc-menu-item { - font-size: 12.5px !important; + font-size: 10.5px !important; font-weight: 500 !important; - height: 34px !important; - min-height: 34px !important; - padding: 0 14px !important; + height: 25px !important; + min-height: 25px !important; + padding: 0 6px !important; + line-height: 25px !important; color: $gray-700 !important; - font-family: 'DM Sans', Roboto, sans-serif !important; + font-family: 'IBM Plex Sans', Roboto, sans-serif !important; + white-space: nowrap !important; + + .menu-item-icon { + font-size: 10.5px !important; + margin-right: 5px !important; + opacity: 0.8; + } &:hover { background: $brand-hover !important; @@ -728,39 +795,44 @@ $gray-700: #334155; } /* Deposits Mega Panel (inside mat-menu overlay) */ -.deposit-menu-grid { +::ng-deep .deposit-menu-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px 24px; - padding: 8px 12px; - min-width: 600px; + gap: 4px 12px; + padding: 6px 10px; + min-width: 0; + width: 100%; + box-sizing: border-box; + overflow-x: hidden; } -.deposit-menu-group { +::ng-deep .deposit-menu-group { .deposit-group-title { font-weight: 700; - font-size: 11px; + font-size: 9.5px; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0.5px; color: #1074b9; - padding: 4px 16px 6px; + padding: 2px 6px 3px; margin-bottom: 2px; - border-bottom: 2px solid #1074b9; + border-bottom: 1.5px solid #1074b9; + white-space: nowrap; } } -/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */ -.npa-grid { - grid-template-columns: repeat(4, minmax(200px, 1fr)); - min-width: 880px; - max-width: 96vw; +/* NPA Mega Panel — 3 columns */ +::ng-deep .npa-grid { + grid-template-columns: repeat(3, minmax(210px, 1fr)); + min-width: 0; + max-width: 100%; + overflow-x: hidden; } -/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */ -.reconciliation-grid { - grid-template-columns: repeat(5, minmax(190px, 1fr)); - min-width: 1040px; - max-width: 96vw; +/* Reconciliation Mega Panel — 5 columns */ +::ng-deep .reconciliation-grid { + grid-template-columns: repeat(5, minmax(170px, 1fr)); + min-width: 0; + max-width: 100%; + overflow-x: hidden; .deposit-group-title { color: #0d9488; @@ -780,47 +852,49 @@ $gray-700: #334155; .gl-mega-menu { position: absolute; - top: 100px; + top: 96px; left: 50%; transform: translateX(-50%); background: #fff; - width: 860px; - max-width: 95vw; - padding: 20px 24px; - box-shadow: 0 6px 24px rgb(0 0 0 / 22%); - border-radius: 0 0 10px 10px; + width: 760px; + max-width: 94vw; + max-height: 72vh; + overflow-y: auto; + padding: 12px 16px; + box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + border-radius: 0 0 8px 8px; z-index: 1000; - border-top: 3px solid #0d47a1; + border-top: 2.5px solid #0d47a1; } .gl-mega-menu-header { display: flex; align-items: center; - gap: 10px; - margin-bottom: 14px; - padding-bottom: 12px; + gap: 8px; + margin-bottom: 8px; + padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; span { - font-size: 18px; + font-size: 14px; font-weight: 700; color: #0d47a1; - letter-spacing: 0.02em; + letter-spacing: 0.01em; } } .gl-mega-menu-header-icon { - font-size: 18px; + font-size: 14px; color: #0d47a1; } .gl-search { width: 100%; - padding: 10px 14px; - margin-bottom: 16px; + padding: 6px 10px; + margin-bottom: 10px; border: 1px solid #d0d5dd; - border-radius: 6px; - font-size: 14px; + border-radius: 5px; + font-size: 12px; box-sizing: border-box; background: #f9fafb; @@ -828,36 +902,37 @@ $gray-700: #334155; outline: none; border-color: #0d47a1; background: #fff; - box-shadow: 0 0 0 3px rgb(13 71 161 / 10%); + box-shadow: 0 0 0 2px rgb(13 71 161 / 10%); } } .gl-columns { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 24px; + gap: 10px 16px; } .gl-group-title { font-weight: 700; - font-size: 11px; + font-size: 10px; text-transform: uppercase; - letter-spacing: 0.8px; + letter-spacing: 0.6px; color: #0d47a1; - margin-bottom: 10px; - padding-bottom: 6px; - border-bottom: 2px solid #0d47a1; + margin-bottom: 5px; + padding-bottom: 3px; + border-bottom: 1.5px solid #0d47a1; } .gl-link { display: flex; align-items: center; - padding: 7px 6px; + padding: 3.5px 5px; text-decoration: none; color: #344054; - font-size: 13px; + font-size: 11.5px; + line-height: 1.3; cursor: pointer; - border-radius: 5px; + border-radius: 4px; transition: background 0.15s, color 0.15s; @@ -873,23 +948,21 @@ $gray-700: #334155; } .gl-icon { - width: 18px; - margin-right: 8px; + width: 14px; + margin-right: 5px; color: #667085; - font-size: 13px; + font-size: 11px; + flex-shrink: 0; .gl-link:hover & { color: #0d47a1; } } -// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired -// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance -// which items in a mega-menu are functional without having to click every one of them. .gl-link--live::after { content: ''; - width: 6px; - height: 6px; + width: 5px; + height: 5px; margin-left: auto; flex-shrink: 0; border-radius: 50%; @@ -899,9 +972,9 @@ $gray-700: #334155; /* RBI Mega Menu */ .rbi-mega-menu { - width: 1100px; - max-width: 97vw; - max-height: 80vh; + width: 880px; + max-width: 94vw; + max-height: 72vh; overflow-y: auto; border-top-color: #b71c1c; } @@ -909,7 +982,7 @@ $gray-700: #334155; .rbi-columns { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 20px; + gap: 10px 16px; } @keyframes move { diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 947cb6e68..46a9edad8 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -522,7 +522,7 @@
- + + +
+
The new slab is added as the last column; fill in its rates for each product.
+
{{ addSlabError }}
+
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss index 476337e6e..25e958ccc 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss @@ -137,6 +137,12 @@ } } + &__add-hint { + margin-top: 8px; + font-size: 12px; + color: #64748b; + } + &__add-error { margin-top: 8px; font-size: 12px; diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts index 95472abde..1900d114c 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts @@ -227,6 +227,7 @@ export class EditRegisterComponent implements OnInit, OnDestroy { if (!this.canEdit) { return; } + this.addingSlab = false; this.addingProduct = true; this.newProductName = ''; this.newProductGl = null; @@ -271,23 +272,67 @@ export class EditRegisterComponent implements OnInit, OnDestroy { } } - /** Append a new amount slab column; every existing row grows by one blank cell. */ + /** "Add slab" panel state. A slab is an amount band; at least a label or an upper limit is needed. */ + addingSlab = false; + newSlabLabel = ''; + newSlabAmount: number | null = null; + addSlabError = ''; + + /** Opens the Add slab panel. */ addSlab(): void { if (!this.canEdit) { return; } - const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab'); + this.addingProduct = false; + this.addingSlab = true; + this.newSlabLabel = ''; + this.newSlabAmount = null; + this.addSlabError = ''; + } + + cancelAddSlab(): void { + this.addingSlab = false; + this.addSlabError = ''; + } + + /** Label that is filled in for a slab when only the upper limit is given, e.g. "Upto 50,00,000". */ + suggestedSlabLabel(): string { + const amount = this.newSlabAmount; + return amount != null && Number.isFinite(amount) && amount > 0 ? `Upto ${amount.toLocaleString('en-IN')}` : ''; + } + + /** Append the new amount slab as the last column; every existing row grows by one blank cell. */ + confirmAddSlab(): void { + const amount = this.newSlabAmount; + const hasAmount = amount != null && String(amount) !== ''; + if (hasAmount && (!Number.isFinite(amount) || (amount as number) <= 0)) { + this.addSlabError = 'The upper limit must be a number greater than 0.'; + return; + } + const label = this.newSlabLabel.trim() || this.suggestedSlabLabel(); if (!label) { + this.addSlabError = 'Enter a label, or an upper limit to build one from.'; + return; + } + if (this.slabs.some((s) => s.label.trim().toLowerCase() === label.toLowerCase())) { + this.addSlabError = 'A slab with this label already exists.'; + return; + } + if (hasAmount && this.slabs.some((s) => s.amountUpto === amount)) { + this.addSlabError = 'A slab with this upper limit already exists.'; + return; + } + if (!hasAmount && this.slabs.some((s) => s.amountUpto === null)) { + this.addSlabError = 'An open-ended slab (no upper limit) already exists.'; return; } - const amountText = window.prompt('Amount upper bound in ₹ (leave blank for no cap)', ''); - const amountUpto = amountText && amountText.trim() ? Number(amountText) : null; this.slabs.push({ key: `s_${Date.now()}`, - label: label.trim(), - amountUpto: amountUpto !== null && !Number.isFinite(amountUpto) ? null : amountUpto + label, + amountUpto: hasAmount ? (amount as number) : null }); this.rows.forEach((r) => r.rates.push(null)); + this.cancelAddSlab(); } editCibil(): void { diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html index dd57f3b14..cf9e44f33 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -71,7 +71,7 @@ > - {{ r.name }} + {{ r.name || '(Untitled draft)' }} {{ statusLabel(r.status) }} diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts index 6614572f5..71e7c00b5 100644 --- a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts @@ -11,6 +11,12 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { + SubmitDetailsData, + SubmitDetailsDialogComponent, + SubmitDetailsResult +} from '../submit-details-dialog/submit-details-dialog.component'; import { RateRegisterService } from '../rate-register.service'; import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; import { RateRegisterDiffComponent } from '../rate-register-diff/rate-register-diff.component'; @@ -41,6 +47,7 @@ export class PreviewRegisterComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly snack = inject(MatSnackBar); + private readonly dialog = inject(MatDialog); draft!: RateRegister; active?: RateRegister; @@ -98,6 +105,33 @@ export class PreviewRegisterComponent implements OnInit { if (this.submitting) { return; } + const ref = this.dialog.open( + SubmitDetailsDialogComponent, + { + width: '520px', + maxWidth: '95vw', + data: { + name: this.draft.name, + effectiveFrom: this.draft.effectiveFrom, + effectiveTo: this.draft.effectiveTo ?? '', + activeName: this.active && this.active.id !== this.draft.id ? this.active.name : undefined, + activeFrom: this.active && this.active.id !== this.draft.id ? this.active.effectiveFrom : undefined + } + } + ); + ref.afterClosed().subscribe((details) => { + if (!details) { + return; + } + this.draft.name = details.name; + this.draft.effectiveFrom = details.effectiveFrom; + this.draft.effectiveTo = details.effectiveTo || null; + this.service.saveDrafts(); + this.send(); + }); + } + + private send(): void { this.submitting = true; this.service.submitForApproval(this.draft.id).subscribe({ next: (res) => { diff --git a/src/app/products/loan-interest-rate-register/rate-register.service.ts b/src/app/products/loan-interest-rate-register/rate-register.service.ts index 9230ad7a5..16dfa332c 100644 --- a/src/app/products/loan-interest-rate-register/rate-register.service.ts +++ b/src/app/products/loan-interest-rate-register/rate-register.service.ts @@ -425,9 +425,10 @@ export class RateRegisterService { const today = new Date().toISOString().slice(0, 10); const copy: RateRegister = { id: newId, - name: `${source.name} — new version`, - effectiveFrom: source.effectiveFrom, - effectiveTo: source.effectiveTo, + // Left blank on purpose: the maker enters name and dates in the submit popup. + name: '', + effectiveFrom: '', + effectiveTo: null, currency: source.currency, status: 'Draft', createdBy: 'Current User', diff --git a/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts new file mode 100644 index 000000000..e815d80f3 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts @@ -0,0 +1,139 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + +export interface SubmitDetailsData { + name: string; + effectiveFrom: string; + effectiveTo: string; + /** Active version this one replaces, shown as context. */ + activeName?: string; + activeFrom?: string; +} + +export interface SubmitDetailsResult { + name: string; + effectiveFrom: string; + effectiveTo: string; +} + +/** Asks the maker for the version details (name, effective from / to) just before sending for approval. */ +@Component({ + selector: 'mifosx-submit-details-dialog', + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

Version details

+ +

+ Confirm these details before sending the rate register for approval. + It will replace {{ data.activeName }} (effective {{ data.activeFrom }}) once approved. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
{{ error }}
+
+ + + + + `, + styles: [ + ` + .sd { + min-width: 460px; + display: flex; + flex-direction: column; + gap: 12px; + } + .sd__hint { + margin: 0; + font-size: 12px; + color: #64748b; + } + .sd__row { + display: flex; + gap: 12px; + } + .sd__field { + display: flex; + flex-direction: column; + flex: 1; + } + .sd__field label { + font-size: 10px; + font-weight: 600; + color: #64748b; + letter-spacing: 0.3px; + margin-bottom: 3px; + } + .sd__field input { + height: 34px; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + font-family: inherit; + } + .sd__error { + font-size: 12px; + color: #b91c1c; + } + ` + ] +}) +export class SubmitDetailsDialogComponent { + readonly ref = inject>(MatDialogRef); + readonly data = inject(MAT_DIALOG_DATA); + + name = this.data.name; + effectiveFrom = this.data.effectiveFrom; + effectiveTo = this.data.effectiveTo; + error = ''; + + confirm(): void { + const name = this.name.trim(); + if (!name) { + this.error = 'Enter the register name.'; + return; + } + if (!this.effectiveFrom) { + this.error = 'Select the effective from date.'; + return; + } + if (this.effectiveTo && this.effectiveTo < this.effectiveFrom) { + this.error = 'Effective to cannot be before effective from.'; + return; + } + this.ref.close({ name, effectiveFrom: this.effectiveFrom, effectiveTo: this.effectiveTo }); + } +} diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html index abc3da5c8..d900c8414 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html @@ -75,7 +75,10 @@ INTEREST
- +
@@ -114,6 +117,77 @@ } } + @if (isLoanAccount) { + + + + SECURITY & COLLATERAL + +
+ +
+
+ + + + + AMOUNT & TENURE + +
+ +
+
+ + + + + REPAYMENT & EMI + +
+ +
+
+ + + + + INTEREST + +
+ +
+
+ + + + + PENAL & NPA + +
+ +
+
+ + + + + PREPAYMENT & CLOSURE + +
+ +
+
+ } + + @@ -193,6 +268,7 @@ + diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 50c14db3b..005e57c45 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts @@ -24,6 +24,11 @@ import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-p import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component'; import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component'; +import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component'; +import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component'; +import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component'; +import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component'; +import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component'; /** Custom Services */ import { ProductsService } from 'app/products/products.service'; @@ -63,12 +68,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; LoanProductPenalNpaStepComponent, LoanProductRenewalClosureStepComponent, LoanProductChequeBookStepComponent, - LoanProductSettingsStepComponent, - LoanProductInterestRefundStepComponent, - LoanProductPaymentStrategyStepComponent, - LoanProductTermsStepComponent, + LoanProductLoanSecurityStepComponent, + LoanProductLoanAmountTenureStepComponent, + LoanProductLoanRepaymentStepComponent, + LoanProductLoanInterestStepComponent, + LoanProductLoanPrepaymentStepComponent, + // Old Loans A/C path — commented out for now (its tabs are commented out in the template): + // LoanProductSettingsStepComponent, + // LoanProductInterestRefundStepComponent, + // LoanProductPaymentStrategyStepComponent, + // LoanProductTermsStepComponent, + // LoanProductDeferredIncomeRecognitionStepComponent, LoanProductChargesStepComponent, - LoanProductDeferredIncomeRecognitionStepComponent, LoanProductAccountingStepComponent, LoanProductPreviewStepComponent ] @@ -97,6 +108,18 @@ export class CreateLoanProductComponent implements OnInit { loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent; @ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent; + // Conditional tabs (Loans A/C only): resolved after the @if renders them. + @ViewChild(LoanProductLoanSecurityStepComponent) + loanProductLoanSecurityStep: LoanProductLoanSecurityStepComponent; + @ViewChild(LoanProductLoanAmountTenureStepComponent) + loanProductLoanAmountTenureStep: LoanProductLoanAmountTenureStepComponent; + @ViewChild(LoanProductLoanRepaymentStepComponent) + loanProductLoanRepaymentStep: LoanProductLoanRepaymentStepComponent; + @ViewChild(LoanProductLoanInterestStepComponent) + loanProductLoanInterestStep: LoanProductLoanInterestStepComponent; + @ViewChild(LoanProductLoanPrepaymentStepComponent) + loanProductLoanPrepaymentStep: LoanProductLoanPrepaymentStepComponent; + /** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */ readonly multiDisburseFallback = new UntypedFormControl(false); @ViewChild(LoanProductInterestRefundStepComponent, { static: true }) @@ -179,6 +202,11 @@ export class CreateLoanProductComponent implements OnInit { return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft; } + /** The GL account chosen on the Details step; the rate register row can be matched by it. */ + get productGlAccount(): number | string | null { + return this.loanProductDetailsStep?.loanProductDetailsForm?.get('glAccount')?.value ?? null; + } + /** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */ get advanceSubType(): string { return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? ''; @@ -282,16 +310,26 @@ export class CreateLoanProductComponent implements OnInit { return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; } + /** The Details step's chosen product type; drives the Loans A/C (EMI) tabs. */ + get isLoanAccount(): boolean { + return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'loans'; + } + get totalTabs(): number { let tabs = 4; // details, currency, charges, accounting (always) if (this.isAdvanceProduct) { // security & lien, limit & tenure, interest, penal & npa, renewal & closure tabs += 5; if (this.isOverdraftFacility) tabs += 1; // cheque book - } else { - tabs += 2; // settings, terms - if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + } else if (this.isLoanAccount) { + // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure + tabs += 6; } + // Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out: + // else { + // tabs += 2; // settings, terms + // if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + // } if (this.loanProductFormValid) tabs += 1; // preview return tabs; } @@ -313,12 +351,28 @@ export class CreateLoanProductComponent implements OnInit { ); } - // Loans A/C: original Settings + Terms path. - const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; - if (this.isAdvancedPaymentStrategy) { - return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + // Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out. + if (this.isLoanAccount) { + return ( + base && + !!this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm?.valid && + !!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid && + !!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid && + !!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid && + !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && + !!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid + ); } - return base && loansValid; + + // No product type chosen yet. + return false; + + // Old Loans A/C path (Settings + Terms) — commented out for now: + // const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; + // if (this.isAdvancedPaymentStrategy) { + // return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + // } + // return base && loansValid; } get loanProduct() { @@ -328,7 +382,13 @@ export class CreateLoanProductComponent implements OnInit { return this.assembleAdvanceProduct(); } - // Loans A/C: original composition (Settings/Terms present). + // Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields. + if (this.isLoanAccount) { + return this.assembleLoanProduct(); + } + return {}; + + /* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now. const loanProduct = { ...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductCurrencyStep.loanProductCurrency, @@ -364,6 +424,181 @@ export class CreateLoanProductComponent implements OnInit { } } return loanProduct; + */ + } + + /** + * Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core + * product fields (so the standard product saves). The Loans-only options that have no core field yet + * (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only + * and are not sent — an unknown key would be rejected by checkForUnsupportedParameters. + */ + private assembleLoanProduct(): any { + const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v)); + + const d = this.loanProductDetailsStep.loanProductDetails; + const cur = this.loanProductCurrencyStep.loanProductCurrency; + const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure; + const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment; + const intr = this.loanProductLoanInterestStep.loanProductLoanInterest; + const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity; + const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment; + const pn = this.loanProductPenalNpaStep.loanProductPenalNpa; + + const stepper = this.loanProductLoanRepaymentStep; + const interestStep = this.loanProductLoanInterestStep; + const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2; + const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1; + const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1; + + const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code; + const minTenure = num(at.minTenure); + const minRate = num(intr.minRate); + const multiTranche = at.disbursementType === 'multiple'; + const moratorium = num(rp.moratoriumMonths); + + const payload: any = { + // identity + currency + name: d.name, + shortName: d.shortName, + description: d.description, + startDate: d.startDate, + closeDate: d.closeDate, + currencyCode: cur.currencyCode, + digitsAfterDecimal: cur.digitsAfterDecimal, + inMultiplesOf: cur.inMultiplesOf ?? 1, + + // amount -> principal + minPrincipal: num(at.minAmount), + principal: num(at.defaultAmount) ?? num(at.minAmount), + maxPrincipal: num(at.maxAmount), + + // tenure (months) -> number of repayments + minNumberOfRepayments: minTenure, + numberOfRepayments: num(at.defaultTenure) ?? minTenure, + maxNumberOfRepayments: num(at.maxTenure), + repaymentEvery: num(rp.repaidEvery) ?? 1, + repaymentFrequencyType: frequency, + graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined, + + // rate + minInterestRatePerPeriod: minRate, + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, + maxInterestRatePerPeriod: num(intr.maxRate), + interestRateFrequencyType: 3, // per year + + // amortization / calculation + amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments + interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat + interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period + transactionProcessingStrategyCode: defaultStrategy, + daysInYearType: daysInYear, + daysInMonthType: daysInMonth, + isInterestRecalculationEnabled: false, + + // disbursement + multiDisburseLoan: multiTranche, + maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined, + + // NPA / delinquency map onto core product fields + overdueDaysForNPA: num(pn.npaDays), + graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing), + inArrearsTolerance: num(pn.inArrearsTolerance), + delinquencyBucketId: pn.delinquencyBucketId || undefined, + + // accounting rule + GL mappings + charges (all standard, whitelisted) + ...this.loanProductAccountingStep.loanProductAccounting, + ...this.loanProductChargesStep.loanProductCharges, + + // Details-step satellite params (same whitelist the Advances path uses) + productTypeGroup: d.productTypeGroup, + productTypeSubGroup: d.productTypeSubGroup, + glAccount: d.glAccount || undefined, + loanProductCategory: d.loanProductCategory, + prioritySector: d.prioritySector, + cibilAccountType: d.cibilAccountType, + kfsEnabled: d.kfsEnabled, + aprEnabled: d.aprEnabled, + + // Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields) + margin: num(sec.margin), + ltv: sec.ltv, + securityType: sec.securityType, + repaymentType: rp.repaymentType, + appropriationOrder: rp.appropriationOrder, + interestCalcMethod: intr.calcMethod, + spread: num(intr.spread), + lockInMonths: num(pp.lockInMonths), + interestOnPreclosure: pp.interestOnPreclosure, + penalApplicable: pn.penalApplicable, + penalRate: num(pn.penalRate), + penalAppliedOn: pn.penalAppliedOn, + penalGraceDays: num(pn.penalGraceDays), + npaTrigger: pn.npaTrigger, + interestAfterNpa: pn.interestAfterNpa, + npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, + npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, + securityMandatory: sec.securityMandatory, + guarantorsRequired: num(sec.guarantorsRequired), + coApplicant: sec.coApplicant, + thirdPartySecurity: sec.thirdPartySecurity, + legalOpinionRequired: sec.legalOpinionRequired, + propertyInsuranceRequired: sec.propertyInsuranceRequired, + registerCharge: sec.registerCharge, + releaseSecurityOnClosure: sec.autoReleaseOnClosure, + allowMultipleSecurities: sec.allowMultipleSecurities, + allowThirdPartyDeposit: sec.allowThirdPartyDeposit, + minAge: num(at.minAge), + maxAgeAtMaturity: num(at.maxAgeAtMaturity), + minCibil: num(at.minCibil), + minMonthlyIncome: num(at.minMonthlyIncome), + maxFoir: num(at.maxFoir), + maxLoansPerCustomer: num(at.maxLoansPerCustomer), + disbursementType: at.disbursementType, + disbursementMode: at.disbursementMode, + undisbursedInterest: at.undisbursedInterest, + preEmiInterest: at.preEmiInterest, + emiDueDay: rp.emiDueDay, + holidayRule: rp.holidayRule, + moratoriumMonths: num(rp.moratoriumMonths), + moratoriumInterest: rp.moratoriumInterest, + variableInstallments: rp.variableInstallments, + repaymentMode: rp.repaymentMode, + linkedAccountType: rp.linkedAccountType, + pdcCollection: rp.pdcCollection, + mandateRequired: rp.mandateRequired, + advanceEmi: rp.advanceEmi, + rateType: intr.interestType, + registerParticular: intr.registerParticular, + applyCibilAdjustment: intr.applyCibilAdjustment, + rateResolvedAt: intr.rateResolvedAt, + calcPeriod: intr.calcPeriod, + interestStartsFrom: intr.interestStartsFrom, + compounding: intr.compounding, + benchmark: intr.benchmark, + resetFrequency: intr.resetFrequency, + resetChange: intr.resetChange, + partPrepaymentAllowed: pp.partPrepaymentAllowed, + minPartPayment: num(pp.minPartPayment), + allowedOn: pp.allowedOn, + partPaymentsPerYear: pp.partPaymentsPerYear, + partPrepaymentMode: pp.partPrepaymentMode, + foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual), + foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual), + foreclosureNonIndividual: num(pp.foreclosureNonIndividual), + foreclosureTakeover: num(pp.foreclosureTakeover), + foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays), + autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance, + nocLetter: pp.nocLetter, + restructuring: pp.restructuring, + releaseCollateralOnClosure: pp.releaseCollateralOnClosure, + topUpAllowed: pp.topUpAllowed, + balanceTransferOutAllowed: pp.balanceTransferOutAllowed + }; + + // Drop undefined keys so we never send an unresolved value. + Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); + return payload; } /** @@ -413,7 +648,8 @@ export class CreateLoanProductComponent implements OnInit { // rate minInterestRatePerPeriod: minRate, - interestRatePerPeriod: minRate, + // A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default. + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, maxInterestRatePerPeriod: num(intr.maxRate), interestRateFrequencyType: 3, // per year @@ -457,6 +693,7 @@ export class CreateLoanProductComponent implements OnInit { lienCertificatePrefix: sec.lienCertificatePrefix, printCertificateOnDisbursement: sec.printOnDisbursement, interestBasis: intr.interestBasis, + registerParticular: intr.registerParticular || undefined, spread: num(intr.spread), interestChargedOn: intr.interestChargedOn, interestPostingFrequency: intr.postingFrequency, diff --git a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss index 29d9a6b7e..062f2f8fe 100644 --- a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss +++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss @@ -172,3 +172,45 @@ $gray-900: #202124; color: #075985; line-height: 1.5; } + +/* Read-only reference tables (Loans A/C steps): collateral rules, foreclosure charges. */ +.adv-table { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $border; + } + + td { + font-size: 14px; + padding: 10px 24px; + border-bottom: 1px solid #eef2f6; + color: #1e293b; + } + + .adv-table__no { + color: #94a3b8; + } + + .adv-table__yes { + color: #047857; + font-weight: 600; + } +} + +.adv-type-options--3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html index d81f96fbf..2c616c118 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html @@ -11,36 +11,14 @@

Rate

+ Taken from the Loan Interest Rate Register
-
- - Interest Basis - - @for (opt of interestBasisOptions; track opt.id) { - {{ opt.label }} - } - - - @if (showSpread) { - - Spread over Deposit Rate (%) - - Advance rate = deposit rate + spread - - } @else { -
- } -
- - Minimum Rate (% p.a.) - - Minimum Rate is required - - - Maximum Rate (% p.a.) - - Maximum Rate is required - +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts index 89dbb9956..4aa3d2dda 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, Input, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component'; /** * Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds, @@ -18,13 +19,23 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; selector: 'mifosx-loan-product-advance-interest-step', templateUrl: './loan-product-advance-interest-step.component.html', styleUrls: ['../advance-steps.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RegisterRateViewComponent + ] }) export class LoanProductAdvanceInterestStepComponent { private formBuilder = inject(UntypedFormBuilder); loanProductAdvanceInterestForm: UntypedFormGroup; + /** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */ + @Input() productSubType = ''; + @Input() glAccountId: number | string | null = null; + + /** The register row the product takes its rate from; null while the register has no row for it. */ + registerSelection: RegisterRateSelection | null = null; + readonly interestBasisOptions = [ { id: 'deposit_spread', label: 'Deposit Rate + Spread' }, { id: 'fixed', label: 'Fixed rate' }, @@ -77,11 +88,12 @@ export class LoanProductAdvanceInterestStepComponent { Validators.max(20) ] ], - minRate: [ - '', - Validators.required - ], - maxRate: [ + // Filled from the register row; never typed in. + minRate: [''], + maxRate: [''], + defaultRate: [''], + // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created. + registerParticular: [ '', Validators.required ], @@ -95,6 +107,33 @@ export class LoanProductAdvanceInterestStepComponent { }); } + /** + * The register row decides the rate. A row of plain rates fixes the minimum and maximum; a deposit row + * ("1% + FD") is the deposit rate plus a spread, which has no plain minimum or maximum. With no row for the + * product the register particular stays empty, which keeps this step, and so the whole product, invalid until + * the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductAdvanceInterestForm; + if (selection) { + form.patchValue( + { + interestBasis: selection.isFormula ? 'deposit_spread' : 'fixed', + spread: selection.isFormula ? selection.minSpread : 0, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '', + registerParticular: selection.particular + }, + { emitEvent: false } + ); + } else { + form.patchValue({ minRate: '', maxRate: '', defaultRate: '', registerParticular: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + get showSpread(): boolean { return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread'; } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts index 57a5f6dc4..6eb65abd2 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts @@ -17,6 +17,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types'; @Component({ selector: 'mifosx-loan-product-details-step', @@ -110,35 +111,10 @@ export class LoanProductDetailsStepComponent implements OnInit { isExistingProduct = false; /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */ - readonly productTypeGroups = [ - { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, - { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } - ]; + /** Category and type lists are shared with the product list and the loan account product picker. */ + readonly productTypeGroups = PRODUCT_TYPE_GROUPS; - /** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ - readonly productTypeSubGroups: Record = { - advances: [ - { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, - { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, - { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, - { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, - { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, - { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, - { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, - { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, - { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, - { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } - ], - loans: [ - { id: 'term', label: 'Term Loan', shortName: 'TL' }, - { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, - { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, - { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, - { id: 'education', label: 'Education Loan', shortName: 'EL' }, - { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, - { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } - ] - }; + readonly productTypeSubGroups = PRODUCT_SUB_TYPES; /** Regulatory Classification options (UI-only; RBI / bureau reporting). */ readonly loanProductCategoryOptions = [ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html new file mode 100644 index 000000000..72b1d72d7 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html @@ -0,0 +1,150 @@ + + +
+
+
+ +

Loan Amount

+
+
+ + Minimum Amount (₹) + + Minimum Amount is required + + + Default Amount (₹) + + + + Maximum Amount (₹) + + Maximum Amount is required + +
+
+
+ Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be + drawn and repaid again. +
+
+
+ +
+
+ +

Tenure

+
+
+ + Minimum Tenure (months) + + Minimum Tenure is required + + + Default Tenure (months) + + + + Maximum Tenure (months) + + Age at maturity cap comes from Borrower Eligibility below + Maximum Tenure is required + +
+
+ +
+
+ +

Borrower Eligibility

+
+
+ + Minimum Age (years) + + + + Maximum Age at Maturity (years) + + + + Minimum CIBIL Score + + + + Minimum Monthly Income (₹) + + + + Maximum FOIR / DBR (%) + + + + Maximum Loans per Customer + + +
+
+ +
+
+ +

Disbursement

+
+ + + Single disbursement + Full amount paid out once. + + + Multiple tranches + Paid out in stages, for example as construction progresses. + + +
+ @if (isMultiTranche) { + + Maximum Tranches + + At least 2 tranches + + } + + Disbursement Mode + + @for (opt of disbursementModeOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest on Undisbursed Amount + + @for (opt of undisbursedInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + Pre-EMI interest is charged on each disbursed tranche until the first full EMI + +
+
+ +
+ + + Hidden for Loans A/C: Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque + book. + +
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts new file mode 100644 index 000000000..ff547810f --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts @@ -0,0 +1,106 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure, + * borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product + * fields; eligibility is UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-amount-tenure-step', + templateUrl: './loan-product-loan-amount-tenure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanAmountTenureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanAmountTenureForm: UntypedFormGroup; + + readonly disbursementModeOptions = [ + { id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' }, + { id: 'transfer', label: 'Transfer to A/C only' }, + { id: 'dd', label: 'Demand draft only' } + ]; + + readonly undisbursedInterestOptions = [ + { id: 'none', label: 'None — interest only on disbursed' }, + { id: 'commitment', label: 'Commitment charge on undisbursed' } + ]; + + constructor() { + this.loanProductLoanAmountTenureForm = this.formBuilder.group({ + minAmount: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + defaultAmount: [''], + maxAmount: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + minTenure: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + defaultTenure: [ + '', + Validators.min(1) + ], + maxTenure: [ + 360, + [ + Validators.required, + Validators.min(1) + ] + ], + minAge: [21], + maxAgeAtMaturity: [70], + minCibil: [650], + minMonthlyIncome: [''], + maxFoir: [50], + maxLoansPerCustomer: [1], + disbursementType: ['single'], + maxTranches: [ + 2, + Validators.min(2) + ], + disbursementMode: ['transfer_dd_vendor'], + undisbursedInterest: ['none'], + preEmiInterest: [true] + }); + } + + get isMultiTranche(): boolean { + return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple'; + } + + get loanProductLoanAmountTenure() { + return this.loanProductLoanAmountTenureForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html new file mode 100644 index 000000000..0a08d2380 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html @@ -0,0 +1,161 @@ + + +
+
+
+ +

Interest Type

+
+ + + Fixed + Same rate for the whole tenure + + + Floating + Linked to a benchmark; resets periodically + + + Hybrid + Fixed for a period, then floating + + +
+
+ +
+
+ +

Rate Source

+ Centralised — changes flow to all loans on this product +
+
+ +
+
+ + Apply CIBIL Adjustment + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Spread over Register Rate (%) + + + + Rate Resolved At + + @for (opt of rateResolvedAtOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Calculation

+
+
+ + Calculation Method + + @for (opt of calcMethodOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Year + + @for (opt of daysInYearOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Month + + @for (opt of daysInMonthOptions; track opt.id) { + {{ opt.label }} + } + + + + Calculation Period + + @for (opt of calcPeriodOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest Starts From + + @for (opt of interestStartOptions; track opt.id) { + {{ opt.label }} + } + + + + Compounding + + @for (opt of compoundingOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + @if (isFloatingOrHybrid) { +
+
+ +

Floating Rate Reset

+ Shown only when the type is Floating or Hybrid +
+
+ + Benchmark + + @for (opt of benchmarkOptions; track opt.id) { + {{ opt.label }} + } + + + + Reset Frequency + + @for (opt of resetFrequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + On Reset, Change + + @for (opt of resetChangeOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ } + diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts new file mode 100644 index 000000000..a29bc3534 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts @@ -0,0 +1,188 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component'; + +/** + * Interest step for a Loans A/C (EMI) product (step 6). The rate normally comes from the central + * Loan Interest Rate Register (amount slab + CIBIL adjustment); the min / default / max rate and the + * calculation method feed Fineract's core product fields. Register link, spread and floating reset + * are UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-interest-step', + templateUrl: './loan-product-loan-interest-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatRadioGroup, + MatRadioButton, + RegisterRateViewComponent + ] +}) +export class LoanProductLoanInterestStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanInterestForm: UntypedFormGroup; + + /** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */ + @Input() productSubType = ''; + @Input() glAccountId: number | string | null = null; + + /** The register row the product takes its rate from; null while the register has no row for it. */ + registerSelection: RegisterRateSelection | null = null; + + /** The 16 default particulars of the Loan Interest Rate Register. */ + readonly registerParticulars = [ + 'Housing Loan (Purchase/Construction)', + 'Home Repair/Renovation', + 'Personal Loan (Unsecured)', + 'Personal Loan (Secured/Property)', + 'Two Wheeler Loan', + 'Car Loan (New)', + 'Used Car/Vehicle Loan', + 'Commercial Vehicle Loan', + 'Gold Loan', + 'Business Term Loan', + 'Machinery & Equipment Loan', + 'Cash Credit', + 'Overdraft', + 'Agriculture Loan', + 'Loan against FDR', + 'Loan against RD' + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly rateResolvedAtOptions = [ + { id: 'sanction', label: 'Sanction' }, + { id: 'disbursement', label: 'First disbursement' } + ]; + + readonly calcMethodOptions = [ + { id: 'declining', label: 'Declining Balance' }, + { id: 'flat', label: 'Flat' } + ]; + + /** `fineractId` maps to Fineract's daysInYearType (1 = actual). */ + readonly daysInYearOptions = [ + { id: 'actual', label: 'Actual (365 / 366)', fineractId: 1 }, + { id: '365', label: '365', fineractId: 365 }, + { id: '360', label: '360', fineractId: 360 } + ]; + + /** `fineractId` maps to Fineract's daysInMonthType (1 = actual). */ + readonly daysInMonthOptions = [ + { id: 'actual', label: 'Actual', fineractId: 1 }, + { id: '30', label: '30', fineractId: 30 } + ]; + + readonly calcPeriodOptions = [ + { id: 'same', label: 'Same as repayment period' }, + { id: 'daily', label: 'Daily' } + ]; + + readonly interestStartOptions = [ + { id: 'disbursement', label: 'Disbursement date' }, + { id: 'first_emi', label: 'First EMI date' } + ]; + + readonly compoundingOptions = [ + { id: 'none', label: 'None (simple on outstanding)' }, + { id: 'monthly', label: 'Monthly' } + ]; + + readonly benchmarkOptions = [ + { id: 'repo', label: 'Repo / MCLR / Bank base rate' }, + { id: 'eblr', label: 'External benchmark (EBLR)' } + ]; + + readonly resetFrequencyOptions = [ + { id: 'quarterly', label: 'Quarterly' }, + { id: 'half_yearly', label: 'Half-yearly' }, + { id: 'yearly', label: 'Yearly' } + ]; + + readonly resetChangeOptions = [ + { id: 'tenure', label: 'Tenure (EMI stays constant)' }, + { id: 'emi', label: 'EMI (tenure stays constant)' } + ]; + + constructor() { + this.loanProductLoanInterestForm = this.formBuilder.group({ + interestType: ['fixed'], + // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created. + registerParticular: [ + '', + Validators.required + ], + applyCibilAdjustment: ['YES'], + spread: [ + 0, + [ + Validators.min(0), + Validators.max(20) + ] + ], + rateResolvedAt: ['sanction'], + // Filled from the register row; never typed in. + minRate: [''], + defaultRate: [''], + maxRate: [''], + calcMethod: ['declining'], + daysInYear: ['actual'], + daysInMonth: ['actual'], + calcPeriod: ['same'], + interestStartsFrom: ['disbursement'], + compounding: ['none'], + benchmark: ['repo'], + resetFrequency: ['quarterly'], + resetChange: ['tenure'] + }); + } + + /** + * The register row decides the rate limits. With no row for the product the register particular stays empty, + * which keeps this step, and so the whole product, invalid until the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductLoanInterestForm; + if (selection) { + form.patchValue( + { + registerParticular: selection.particular, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '' + }, + { emitEvent: false } + ); + } else { + form.patchValue({ registerParticular: '', minRate: '', maxRate: '', defaultRate: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + + /** Reset options only apply to a floating or hybrid rate. */ + get isFloatingOrHybrid(): boolean { + const type = this.loanProductLoanInterestForm.get('interestType').value; + return type === 'floating' || type === 'hybrid'; + } + + get loanProductLoanInterest() { + return this.loanProductLoanInterestForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html new file mode 100644 index 000000000..b17ca654c --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html @@ -0,0 +1,144 @@ + + +
+
+
+ +

Part-prepayment

+
+
+ Part-prepayment allowed +
+
+ + Minimum Part-payment (₹) + + + + Allowed Only On + + @for (opt of allowedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Part-payments per Year + + @for (opt of perYearOptions; track opt.id) { + {{ opt.label }} + } + + +
+ + + Reduce tenure + EMI stays the same + + + Reduce EMI + Tenure stays the same + + + Borrower chooses + Picked at payment time + + +
+
+ +
+
+ +

Pre-closure (Foreclosure)

+ Charge on outstanding principal +
+
+ + Individual · floating rate (%) + + Nil as per RBI + + + Individual · fixed rate (%) + + + + Non-individual / business (%) + + + + Takeover by another bank (%) + + +
+
+ + Lock-in Period (months) + + + + Interest on Pre-closure + + @for (opt of interestOnPreclosureOptions; track opt.id) { + {{ opt.label }} + } + + + + Foreclosure Quote Valid (days) + + +
+
+ +
+
+ +

Closure & Restructuring

+
+
+ + Auto-close on Zero Balance + + @for (opt of autoCloseOptions; track opt) { + {{ opt }} + } + + + + NOC / Closure Letter + + @for (opt of nocOptions; track opt.id) { + {{ opt.label }} + } + + + + Restructuring / Re-schedule + + @for (opt of restructuringOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+
+ Release collateral and lien on closure + Top-up loan on the same account + Allow balance transfer (takeover) out +
+
+
+
Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.
+
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts new file mode 100644 index 000000000..e5c6487e8 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts @@ -0,0 +1,117 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Prepayment & Closure step for a Loans A/C (EMI) product (step 8). A loan has no renewal; it ends at + * maturity or on pre-closure. UI-only for now: not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-loan-prepayment-step', + templateUrl: './loan-product-loan-prepayment-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanPrepaymentStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanPrepaymentForm: UntypedFormGroup; + + readonly allowedOnOptions = [ + { id: 'emi_date', label: 'EMI due date' }, + { id: 'any_day', label: 'Any day' } + ]; + + readonly perYearOptions = [ + { id: 'unlimited', label: 'Unlimited' }, + { id: 'one', label: 'Once a year' }, + { id: 'two', label: 'Twice a year' } + ]; + + readonly interestOnPreclosureOptions = [ + { id: 'till_date', label: 'Till date of payment' }, + { id: 'full_month', label: 'Full current month' } + ]; + + readonly autoCloseOptions = [ + 'YES', + 'NO' + ]; + + readonly nocOptions = [ + { id: 'auto', label: 'Auto-generate' }, + { id: 'manual', label: 'On request' } + ]; + + readonly restructuringOptions = [ + { id: 'approval', label: 'Allowed with approval' }, + { id: 'no', label: 'Not allowed' } + ]; + + constructor() { + const percent = [ + Validators.min(0), + Validators.max(100) + ]; + this.loanProductLoanPrepaymentForm = this.formBuilder.group({ + partPrepaymentAllowed: [true], + minPartPayment: [ + '', + Validators.min(0) + ], + allowedOn: ['emi_date'], + partPaymentsPerYear: ['unlimited'], + partPrepaymentMode: ['reduce_tenure'], + foreclosureFloatingIndividual: [ + 0, + percent + ], + foreclosureFixedIndividual: [ + 2, + percent + ], + foreclosureNonIndividual: [ + 3, + percent + ], + foreclosureTakeover: [ + 2, + percent + ], + lockInMonths: [ + 0, + Validators.min(0) + ], + interestOnPreclosure: ['till_date'], + foreclosureQuoteValidDays: [ + 7, + Validators.min(1) + ], + autoCloseOnZeroBalance: ['YES'], + nocLetter: ['auto'], + restructuring: ['approval'], + releaseCollateralOnClosure: [true], + topUpAllowed: [false], + balanceTransferOutAllowed: [true] + }); + } + + get loanProductLoanPrepayment() { + return this.loanProductLoanPrepaymentForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html new file mode 100644 index 000000000..4972737f7 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html @@ -0,0 +1,155 @@ + + +
+
+
+ +

Repayment Type

+
+ + @for (opt of repaymentTypeOptions; track opt.id) { + + {{ opt.label }} + {{ opt.hint }} + + } + +
+
+ This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance. +
+
+
+ +
+
+ +

Schedule

+
+
+ + Repaid Every + + Repaid Every is required + + + Frequency + + @for (opt of frequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + Minimum Days to First Repayment + + + + EMI Due Day + + @for (opt of emiDueDayOptions; track opt.id) { + {{ opt.label }} + } + + + + If Due Date is a Holiday + + @for (opt of holidayRuleOptions; track opt.id) { + {{ opt.label }} + } + + + + Moratorium / Grace (months) + + + + Interest during Moratorium + + @for (opt of moratoriumInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ Variable installments allowed +
+
+ +
+
+ +

Repayment Mode

+
+
+ + Repayment Mode + + @for (opt of repaymentModeOptions; track opt.id) { + {{ opt.label }} + } + + + + Linked Account Type + + @for (opt of linkedAccountOptions; track opt.id) { + {{ opt.label }} + } + + + + PDC Collection + + @for (opt of pdcOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + Mandate registration is required before the first disbursement + +
+
+
+ Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is + therefore not part of a Loan A/C. +
+
+
+ +
+
+ +

Appropriation Order

+
+
+ + Appropriation Order + + @for (opt of appropriationOrderOptions; track opt.id) { + {{ opt.label }} + } + + + + Advance EMI Payment + + @for (opt of advanceEmiOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts new file mode 100644 index 000000000..162f2cd8d --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts @@ -0,0 +1,129 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Repayment & EMI step for a Loans A/C (EMI) product (step 5) — the step an Advance does not have. + * Repayment type and frequency feed Fineract's core product fields; the repayment mode and the + * rest of the schedule options are UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-repayment-step', + templateUrl: './loan-product-loan-repayment-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanRepaymentStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanRepaymentForm: UntypedFormGroup; + + readonly repaymentTypeOptions = [ + { id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' }, + { id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' }, + { id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }, + { id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' }, + { id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' } + ]; + + /** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */ + readonly frequencyOptions = [ + { id: 'months', label: 'Months', fineractId: 2 }, + { id: 'weeks', label: 'Weeks', fineractId: 1 }, + { id: 'days', label: 'Days', fineractId: 0 } + ]; + + readonly emiDueDayOptions = [ + { id: 'disbursement_day', label: 'Same day as disbursement' }, + { id: 'fixed_day', label: 'Fixed day of month (5th)' } + ]; + + readonly holidayRuleOptions = [ + { id: 'next', label: 'Next working day' }, + { id: 'previous', label: 'Previous working day' }, + { id: 'same', label: 'Same day' } + ]; + + readonly moratoriumInterestOptions = [ + { id: 'pre_emi', label: 'Pay monthly (Pre-EMI)' }, + { id: 'capitalise', label: 'Capitalise into loan' } + ]; + + readonly repaymentModeOptions = [ + { id: 'nach', label: 'Auto-debit (NACH / ECS)' }, + { id: 'pdc', label: 'Post-dated cheques (PDC)' }, + { id: 'cash', label: 'Cash / transfer at branch' } + ]; + + readonly linkedAccountOptions = [ + { id: 'savings_current', label: 'Savings / Current' }, + { id: 'savings', label: 'Savings only' } + ]; + + readonly pdcOptions = [ + { id: 'mandatory', label: 'Mandatory — 3 cheques' }, + { id: 'optional', label: 'Optional' }, + { id: 'none', label: 'Not collected' } + ]; + + readonly appropriationOrderOptions = [ + { id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' }, + { id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' } + ]; + + readonly advanceEmiOptions = [ + { id: 'adjust_next', label: 'Allowed — adjust next EMI' }, + { id: 'not_allowed', label: 'Not allowed' } + ]; + + constructor() { + this.loanProductLoanRepaymentForm = this.formBuilder.group({ + repaymentType: ['emi'], + repaidEvery: [ + 1, + [ + Validators.required, + Validators.min(1) + ] + ], + frequency: ['months'], + minDaysToFirstRepayment: [ + '', + Validators.min(0) + ], + emiDueDay: ['fixed_day'], + holidayRule: ['next'], + moratoriumMonths: [ + 0, + Validators.min(0) + ], + moratoriumInterest: ['pre_emi'], + variableInstallments: [false], + repaymentMode: ['nach'], + linkedAccountType: ['savings_current'], + pdcCollection: ['mandatory'], + mandateRequired: [true], + appropriationOrder: ['charges_penal_interest_principal'], + advanceEmi: ['adjust_next'] + }); + } + + get loanProductLoanRepayment() { + return this.loanProductLoanRepaymentForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html new file mode 100644 index 000000000..6650b8f5a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html @@ -0,0 +1,130 @@ + + +
+
+
+ +

Security

+
+
+ + Security Type + + + + Security Mandatory + + @for (opt of securityMandatoryOptions; track opt.id) { + {{ opt.label }} + } + + + + Margin (%) + + Margin must be between 0 and 100 + + + Maximum LTV (%) + + = 100 − Margin + +
+
+ +
+
+ +

Collateral Rules by Security Type

+ Which security this product may accept +
+
+ + + + + + + + + + + + @for (rule of collateralRules; track rule.security) { + + + + + + + + + } + +
SecurityAllowedMax LTV %ValuationInsuranceCharge / lien
{{ rule.security }} + {{ rule.allowed ? 'Yes' : 'No' }} + {{ rule.ltv }}{{ rule.valuation }}{{ rule.insurance }}{{ rule.charge }}
+
+ +
+
+ +

Guarantor & Co-applicant

+
+
+ + Guarantors Required (min) + + + + Co-applicant + + @for (opt of coApplicantOptions; track opt.id) { + {{ opt.label }} + } + + + + Third-party Security + + @for (opt of thirdPartyOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Conditions

+
+
+
+ + Legal opinion / title search mandatory before sanction + + + Property insurance mandatory for the whole tenure + + Register charge (CERSAI) before disbursement + Release security automatically on closure + Allow multiple securities for one loan + Allow third-party deposit as security +
+
+
+
+ Unlike an Advance, the security does not decide a running limit. It only decides eligibility at sanction. + Disbursement happens once, up to the sanctioned amount. +
+
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts new file mode 100644 index 000000000..39caf70e0 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts @@ -0,0 +1,127 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Security & Collateral step for a Loans A/C (EMI) product (step 3). + * + * Unlike an Advance, the security only decides eligibility at sanction — it does not set a running + * limit. UI-only for now: not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-loan-security-step', + templateUrl: './loan-product-loan-security-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductLoanSecurityStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanSecurityForm: UntypedFormGroup; + + readonly securityMandatoryOptions = [ + { id: 'yes', label: 'Yes — no sanction without security' }, + { id: 'no', label: 'No — unsecured' } + ]; + + readonly coApplicantOptions = [ + { id: 'joint_property', label: 'Mandatory for joint property' }, + { id: 'always', label: 'Always mandatory' }, + { id: 'optional', label: 'Optional' } + ]; + + readonly thirdPartyOptions = [ + { id: 'consent', label: 'Allowed with owner consent' }, + { id: 'no', label: 'Not allowed' } + ]; + + /** Reference table: which security a loan on this product may accept. */ + readonly collateralRules = [ + { + security: 'Residential property', + allowed: true, + ltv: '80', + valuation: 'Empanelled valuer', + insurance: 'Mandatory', + charge: 'Mortgage + CERSAI' + }, + { + security: 'Commercial property', + allowed: true, + ltv: '70', + valuation: 'Empanelled valuer', + insurance: 'Mandatory', + charge: 'Mortgage + CERSAI' + }, + { + security: 'Fixed / Recurring Deposit', + allowed: true, + ltv: '90', + valuation: 'Not needed', + insurance: 'Not needed', + charge: 'Lien marked' + }, + { + security: 'LIC policy', + allowed: true, + ltv: '90', + valuation: 'Surrender value', + insurance: 'Not needed', + charge: 'Assignment' + }, + { security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }, + { security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' } + ]; + + constructor() { + this.loanProductLoanSecurityForm = this.formBuilder.group({ + securityType: ['Immovable property (mortgage)'], + securityMandatory: ['yes'], + margin: [ + 20, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + guarantorsRequired: [ + 1, + Validators.min(0) + ], + coApplicant: ['joint_property'], + thirdPartySecurity: ['consent'], + legalOpinionRequired: [true], + propertyInsuranceRequired: [true], + registerCharge: [true], + autoReleaseOnClosure: [true], + allowMultipleSecurities: [false], + allowThirdPartyDeposit: [false] + }); + } + + /** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */ + get ltv(): number { + const margin = Number(this.loanProductLoanSecurityForm.get('margin').value); + return Number.isFinite(margin) ? Math.max(0, 100 - margin) : 100; + } + + get loanProductLoanSecurity() { + return { + ...this.loanProductLoanSecurityForm.getRawValue(), + ltv: this.ltv + }; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html new file mode 100644 index 000000000..377661f29 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html @@ -0,0 +1,97 @@ + + +@switch (state) { + @case ('loading') { +
Reading the Loan Interest Rate Register…
+ } + @case ('none') { +
+ There is no active Loan Interest Rate Register yet. + + A product takes its interest rate from the register, so it cannot be created until a register is created and + approved. + + Open the register → +
+ } + @case ('ready') { + @if (selectedRow) { +
+ + Interest comes from the active register {{ register?.name }}, effective {{ register?.effectiveFrom }}. + + Open register → +
+ } + + @if (!selectedRow) { +
+ This product has no interest rate in the Loan Interest Rate Register yet. + {{ blockReason }} + Add it in the register → +
+ } + + @if (selectedRow) { +
+ Register row + {{ selectedRow.srNo }}. {{ selectedRow.particulars }} + @if (glLabel(selectedRow); as gl) { + {{ gl }} + } + {{ matchNote }} +
+ +
+ + + + + + + + + @for (slab of slabRates; track slab.label) { + + + + + } + +
Amount slabRate
{{ slab.label }}{{ slab.text }}
+ + + + + + + + + + + @for (slab of cibilSlabs; track slab.from) { + + + + + + } + +
CIBIL scoreBandAdded to the rate
{{ slab.from }} – {{ slab.to }}{{ slab.tier }}{{ slab.adjustmentPct > 0 ? '+' + slab.adjustmentPct.toFixed(2) + '%' : 'No change' }}
+
+ @if (selectedRow.isFormula || slabRates[0]?.formula) { +
+ This row is the deposit's own rate plus a spread, so the final rate is worked out on each account from its + deposit. +
+ } + } + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss new file mode 100644 index 000000000..4347cf852 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss @@ -0,0 +1,106 @@ +/** + * 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/. + */ + +:host { + display: block; +} + +.rrv__source { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + a { + font-weight: 600; + white-space: nowrap; + } +} + +.rrv__block { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + margin: 16px 24px 0; + padding: 12px 16px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; + line-height: 1.5; + + strong { + flex: 1 1 100%; + font-size: 14px; + } + + span { + flex: 1 1 360px; + } +} + +.rrv__block-btn { + font-weight: 600; + color: #991b1b; + white-space: nowrap; +} + +.rrv__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + padding: 14px 24px 4px; + font-size: 14px; + color: #1e293b; +} + +.rrv__row-label { + font-size: 12px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #5f6368; +} + +.rrv__row-gl { + padding: 1px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; +} + +.rrv__row-note { + font-size: 12px; + color: #5f6368; +} + +.rrv__tables { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 8px 24px 16px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rrv__formula { + color: #075985; + font-weight: 600; +} + +.rrv__note { + padding: 0 24px 16px; + font-size: 13px; + color: #5f6368; +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts new file mode 100644 index 000000000..44d9a35cc --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts @@ -0,0 +1,220 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service'; +import { + CibilSlab, + RateCell, + RateChartRow, + RateRegister +} from 'app/products/loan-interest-rate-register/models/rate-register.model'; + +/** How the register row was found for the product. */ +export type RegisterMatch = 'gl' | 'type'; + +/** What the product takes from the register row, handed to the step that owns the rate fields. */ +export interface RegisterRateSelection { + particular: string; + srNo: number; + glAccountId: number | null; + /** True when the row holds formulas such as "1% + FD": the rate is the deposit's own rate plus a spread. */ + isFormula: boolean; + /** Lowest and highest plain rate in the row, the top one including the largest CIBIL adjustment. Null for a formula row. */ + minRate: number | null; + maxRate: number | null; + /** The rate the product is created with: the lowest plain rate, or the lowest spread for a formula row. */ + defaultRate: number | null; + /** Lowest and highest spread over the deposit rate; only for a formula row. */ + minSpread: number | null; + maxSpread: number | null; + matchedBy: RegisterMatch; + registerName: string; +} + +/** + * Product sub type id -> a word of the register particular that goes with it. This only covers the register's + * default rows. Any other product is found through its GL account, which every register row carries. + */ +const PARTICULAR_BY_SUB_TYPE: Record = { + fd: 'against fdr', + rd: 'against rd', + gold: 'gold loan', + cc: 'cash credit', + overdraft: 'overdraft', + kcc: 'agriculture', + housing: 'housing loan', + personal: 'personal loan (unsecured)', + vehicle: 'car loan (new)', + business: 'business term loan', + term: 'business term loan', + gold_emi: 'gold loan' +}; + +/** + * Shows the interest of a product as the Loan Interest Rate Register defines it: the rate of the product's own + * register row for each amount slab, and the CIBIL adjustment. Nothing is chosen or typed here. + * + * The row is found from the product's GL account first (the register maps each row to a GL), then from its sub + * type. The row found is reported through `selectionChange`, or null when there is none, which blocks the product. + */ +@Component({ + selector: 'mifosx-register-rate-view', + templateUrl: './register-rate-view.component.html', + styleUrls: [ + '../advance-steps.scss', + './register-rate-view.component.scss' + ], + imports: [RouterLink] +}) +export class RegisterRateViewComponent implements OnInit, OnChanges { + private registerService = inject(RateRegisterService); + + /** Sub type id picked on the Details step, e.g. `fd`. */ + @Input() productSubType = ''; + /** The product's GL account id from the Details step. */ + @Input() glAccountId: number | string | null = null; + @Output() selectionChange = new EventEmitter(); + + state: 'loading' | 'ready' | 'none' = 'loading'; + register?: RateRegister; + selectedSrNo: number | null = null; + matchedBy: RegisterMatch | null = null; + /** More than one register row fits the product and nothing tells them apart. */ + ambiguous = false; + + ngOnInit(): void { + this.registerService.getGlOptions().subscribe(); + this.registerService.list().subscribe(() => { + this.register = this.registerService.getActive(); + this.state = this.register && this.register.rows.length > 0 ? 'ready' : 'none'; + this.autoSelect(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + // The Details step can change after this one has opened, and a new type or GL means a new row. + if ((changes['productSubType'] || changes['glAccountId']) && this.state === 'ready') { + this.autoSelect(); + } + } + + get rows(): RateChartRow[] { + return this.register?.rows ?? []; + } + + get selectedRow(): RateChartRow | undefined { + return this.rows.find((row) => row.srNo === this.selectedSrNo); + } + + get cibilSlabs(): CibilSlab[] { + return this.register?.cibilSlabs ?? []; + } + + glLabel(row: RateChartRow): string | null { + return this.registerService.glLabel(row.glAccountId); + } + + /** The row's rate for every amount slab, ready to show. */ + get slabRates(): { label: string; text: string; formula: boolean; na: boolean }[] { + const row = this.selectedRow; + if (!row || !this.register) { + return []; + } + return this.register.slabs.map((slab, index) => { + const cell: RateCell = row.rates[index] ?? null; + return { + label: slab.label, + text: cell === null ? 'N.A.' : typeof cell === 'string' ? cell : `${cell.toFixed(2)}%`, + formula: typeof cell === 'string', + na: cell === null + }; + }); + } + + /** Why the product cannot go on: what the officer has to do in the register first. */ + get blockReason(): string { + const hasGl = this.glAccountId !== null && this.glAccountId !== ''; + if (!this.productSubType && !hasGl) { + return 'Choose the product type and the GL account on the Details step first.'; + } + const gl = hasGl ? this.registerService.glLabel(Number(this.glAccountId)) : null; + if (this.ambiguous) { + return `More than one register row uses the GL account${gl ? ` (${gl})` : ''}. Give each row its own GL account in the register so this product can be matched to one of them.`; + } + const forGl = gl ? ` with its GL account (${gl})` : ' with its GL account'; + return `Add this product${forGl} and its interest rates in the register and get the register approved. The product can be created only after that.`; + } + + /** How the row was found, shown beside it. */ + get matchNote(): string { + return this.matchedBy === 'gl' + ? `Found through the product's GL account (${this.glLabel(this.selectedRow!) ?? 'GL'}).` + : 'Found through the product type.'; + } + + private autoSelect(): void { + const rows = this.rows; + const word = PARTICULAR_BY_SUB_TYPE[this.productSubType]?.toLowerCase(); + const byType = word ? rows.filter((row) => row.particulars.toLowerCase().includes(word)) : []; + const gl = this.glAccountId === null || this.glAccountId === '' ? null : Number(this.glAccountId); + const byGl = gl === null ? [] : rows.filter((row) => row.glAccountId === gl); + + let chosen: RateChartRow | undefined; + let by: RegisterMatch | null = null; + this.ambiguous = false; + if (byGl.length === 1) { + chosen = byGl[0]; + by = 'gl'; + } else if (byGl.length > 1) { + // Several rows share the GL: the product type tells them apart, or nothing does. + chosen = byGl.find((row) => byType.includes(row)); + by = chosen ? 'type' : null; + this.ambiguous = !chosen; + } else if (byType.length === 1) { + chosen = byType[0]; + by = 'type'; + } else if (byType.length > 1) { + this.ambiguous = true; + } + this.selectedSrNo = chosen?.srNo ?? null; + this.matchedBy = by; + this.emit(); + } + + private emit(): void { + const row = this.selectedRow; + if (!row || !this.register) { + this.selectionChange.emit(null); + return; + } + const plain = row.rates.filter((cell): cell is number => typeof cell === 'number'); + const spreads = row.rates + .filter((cell): cell is string => typeof cell === 'string') + .map((text) => /(\d+(?:\.\d+)?)\s*%/.exec(text)?.[1]) + .filter((value): value is string => !!value) + .map(Number); + const topAdjustment = Math.max(0, ...this.cibilSlabs.map((slab) => Number(slab.adjustmentPct) || 0)); + const isFormula = spreads.length > 0 && plain.length === 0; + const minRate = plain.length > 0 ? Math.min(...plain) : null; + this.selectionChange.emit({ + particular: row.particulars, + srNo: row.srNo, + glAccountId: row.glAccountId ?? null, + isFormula, + minRate, + maxRate: plain.length > 0 ? Math.round((Math.max(...plain) + topAdjustment) * 100) / 100 : null, + defaultRate: isFormula ? Math.min(...spreads) : minRate, + minSpread: isFormula ? Math.min(...spreads) : null, + maxSpread: isFormula ? Math.max(...spreads) : null, + matchedBy: this.matchedBy ?? 'type', + registerName: this.register.name + }); + } +} diff --git a/src/app/products/loan-products/loan-product-types.ts b/src/app/products/loan-products/loan-product-types.ts new file mode 100644 index 000000000..f310cef62 --- /dev/null +++ b/src/app/products/loan-products/loan-product-types.ts @@ -0,0 +1,64 @@ +/** + * 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/. + */ + +/** + * The category ("product type") and sub type of a loan product. One list shared by the create-product wizard, the + * product list filters and the loan account product picker, so a type added here appears in all three. + */ + +export interface ProductTypeGroup { + id: string; + label: string; + hint: string; +} + +export interface ProductSubType { + id: string; + label: string; + /** The two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ + shortName: string; +} + +export const PRODUCT_TYPE_GROUPS: ProductTypeGroup[] = [ + { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, + { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } +]; + +export const PRODUCT_SUB_TYPES: Record = { + advances: [ + { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, + { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, + { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, + { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, + { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, + { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, + { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, + { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, + { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, + { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } + ], + loans: [ + { id: 'term', label: 'Term Loan', shortName: 'TL' }, + { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, + { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, + { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, + { id: 'education', label: 'Education Loan', shortName: 'EL' }, + { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, + { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } + ] +}; + +/** Label of a category id, or a dash for a product that carries none (created before categories existed). */ +export function productCategoryLabel(group: string | null | undefined): string { + return PRODUCT_TYPE_GROUPS.find((g) => g.id === group)?.label ?? '—'; +} + +/** Label of a sub type id within its category, or a dash when unknown. */ +export function productTypeLabel(group: string | null | undefined, subGroup: string | null | undefined): string { + return (PRODUCT_SUB_TYPES[group ?? ''] ?? []).find((s) => s.id === subGroup)?.label ?? '—'; +} diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss index e15399058..48f1f175d 100644 --- a/src/app/products/loan-products/loan-products.component.scss +++ b/src/app/products/loan-products/loan-products.component.scss @@ -15,3 +15,49 @@ --pl-accent: #b45309; --pl-accent-l: #fef3c7; } + +/* Category / product type / active-only filters beside the search box. */ +.pl-filters { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.pl-filter { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + color: #64748b; + + select { + height: 34px; + min-width: 160px; + padding: 0 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 13px; + color: #1e293b; + } + + &--check { + flex-direction: row; + align-items: center; + gap: 6px; + height: 34px; + font-size: 13px; + color: #1e293b; + } +} + +.pl-tag--advances { + background: #e0f2fe; + color: #075985; +} + +.pl-tag--loans { + background: #ede9fe; + color: #5b21b6; +} diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts index f16a3bff1..f316a73ab 100644 --- a/src/app/products/loan-products/loan-products.component.ts +++ b/src/app/products/loan-products/loan-products.component.ts @@ -42,6 +42,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types'; @Component({ selector: 'mifosx-loan-products', @@ -82,6 +83,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { displayedColumns: string[] = [ 'name', 'shortName', + 'category', + 'productType', 'closeDate', 'status', 'actions' @@ -90,6 +93,31 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { filteredCount = 0; + /** Category and type filters, fed by the classification each product carries. */ + readonly categories = PRODUCT_TYPE_GROUPS; + categoryFilter = ''; + typeFilter = ''; + activeOnly = false; + textFilter = ''; + + /** Types offered for the chosen category; every type when no category is chosen. */ + get typeOptions() { + return this.categoryFilter + ? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []) + : Object.values(PRODUCT_SUB_TYPES).flat(); + } + + categoryOf(product: any): string { + return productCategoryLabel(product?.advanceExtensions?.productTypeGroup); + } + + typeOf(product: any): string { + return productTypeLabel( + product?.advanceExtensions?.productTypeGroup, + product?.advanceExtensions?.productTypeSubGroup + ); + } + get totalCount(): number { return this.loanProductsData?.length || 0; } @@ -130,12 +158,52 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { this.dataSource = new MatTableDataSource(this.loanProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product); this.filteredCount = this.totalCount; this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = (filterValue || '').trim().toLowerCase(); + this.textFilter = (filterValue || '').trim().toLowerCase(); + this.refreshFilter(); + } + + onCategoryChange(): void { + // A type belongs to one category, so a type from another category cannot stay selected. + if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) { + this.typeFilter = ''; + } + this.refreshFilter(); + } + + /** The filter value itself is unused (the predicate reads the fields above); changing it re-runs the predicate. */ + refreshFilter(): void { + this.dataSource.filter = `${this.categoryFilter}|${this.typeFilter}|${this.activeOnly}|${this.textFilter}`; + } + + private matchesFilters(product: any): boolean { + const ext = product?.advanceExtensions ?? {}; + if (this.categoryFilter && ext.productTypeGroup !== this.categoryFilter) { + return false; + } + if (this.typeFilter && ext.productTypeSubGroup !== this.typeFilter) { + return false; + } + if (this.activeOnly && product?.status !== 'loanProduct.active') { + return false; + } + if (this.textFilter) { + const haystack = [ + product?.name, + product?.shortName, + this.categoryOf(product), + this.typeOf(product) + ] + .join(' ') + .toLowerCase(); + return haystack.includes(this.textFilter); + } + return true; } /** diff --git a/src/app/products/loan-products/loan-products.ts b/src/app/products/loan-products/loan-products.ts index 52e913db3..b097b9b43 100644 --- a/src/app/products/loan-products/loan-products.ts +++ b/src/app/products/loan-products/loan-products.ts @@ -50,10 +50,29 @@ export class LoanProducts { delete loanProduct.advancedAccountingRules; delete loanProduct.loanPeriod; - // The Advances payload is assembled explicitly in the create component and already carries only - // whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped. - // For the Loans path, the Details/Security fields below are not backend params, so drop them. - if (loanProduct.productTypeGroup !== 'advances') { + // The Accounting step carries CBS-specific GL mappings that the backend does not accept yet. They are + // sent as '' when the officer leaves them blank, and the backend rejects the whole product for an + // unsupported parameter, so drop the ones that are empty. A mapping the officer actually selects is still + // sent and gets a clear "not supported" answer rather than being silently ignored. + [ + 'intReceivablesGlAccountId', + 'intPayableGlAccountId', + 'deafIntPayableGlAccountId', + 'intOverdueReserveAccountId', + 'undebitedInterestAccountId', + 'penalIntCrGlAccountId', + 'rdMaturedGlAccountId' + ].forEach((key) => { + if (loanProduct[key] === '' || loanProduct[key] === null || loanProduct[key] === undefined) { + delete loanProduct[key]; + } + }); + + // The Advances and Loans A/C (EMI) payloads are assembled explicitly in the create component and already + // carry only whitelisted params (core Fineract fields + satellite params), so they are NOT stripped. + // For any other product (the old Settings/Terms path), the Details/Security fields below are not backend + // params, so drop them. + if (loanProduct.productTypeGroup !== 'advances' && loanProduct.productTypeGroup !== 'loans') { delete loanProduct.productTypeGroup; delete loanProduct.productTypeSubGroup; delete loanProduct.glAccount; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 94bc5c969..956c04858 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261008', - 'hash': 'a5a64d4ef' + 'version': '261010', + 'hash': 'd61584f29' }, 'allow_switching_backend_instance': true }; From 211027b9d8e8c236acc590304b356fe6de3026e4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 10:57:06 +0530 Subject: [PATCH 19/21] fix(bank-calendar): follow Admin > Working Days for weekly offs The Bank Calendar never read the Working Days setting. It hard-coded Mon-Sat as working days and always marked only Sundays and the 2nd and 4th Saturday as off, so unticking Saturday (or any weekday) in Admin > Working Days changed nothing on the calendar. - Load the setting from /workingdays and parse its recurrence (BYDAY=MO,TU,...). Until it answers, or if it cannot be read, the old Mon-Sat default stays in place. - A weekday left out of Working Days is off every week. When Saturday is left out, every Saturday is off; when it is a working day, the RBI rule still keeps the 2nd and 4th Saturday off. Sunday shows as a working day if Working Days ticks it. - Month and year counts, the Saturday stat card label ("2nd & 4th Sat" or "Saturdays"), the legend, tooltips and the month summary tags ("N Sat Off", "N Weekly Off") follow the same rule. Verified with prettier, eslint, tsc and ng build. Co-Authored-By: Claude Opus 5.5 --- .../bank-calendar.component.html | 32 +++++-- .../bank-calendar/bank-calendar.component.ts | 86 ++++++++++++++++--- 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/src/app/bank-calendar/bank-calendar.component.html b/src/app/bank-calendar/bank-calendar.component.html index b7f912e0f..d4996f7fa 100644 --- a/src/app/bank-calendar/bank-calendar.component.html +++ b/src/app/bank-calendar/bank-calendar.component.html @@ -44,8 +44,11 @@
-
{{ overallStats.sat2nd4th }}
-
2nd & 4th Sat
+
{{ overallStats.sat2nd4th + overallStats.weeklyOff }}
+
+ {{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays' + }}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }} +
@@ -69,7 +72,11 @@
Working Day
Sunday
-
2nd / 4th Saturday
+
+ + {{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday' + }}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }} +
Holiday
Today
@@ -319,7 +326,9 @@ [class.cal-grid__day--other]="!day.isCurrentMonth" [class.cal-grid__day--today]="day.isToday" [class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth" - [class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth" + [class.cal-grid__day--sat2nd4th]=" + (day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth + " [class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth" [class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth" [matTooltip]=" @@ -328,9 +337,11 @@ ? '2nd Saturday Off' : day.is4thSaturday ? '4th Saturday Off' - : day.isSunday && day.isCurrentMonth - ? 'Sunday' - : '') + : day.isWeeklyOff && day.isCurrentMonth + ? weekDays[day.dayOfWeek] + ' Off (Working Days)' + : day.isSunday && day.isCurrentMonth + ? 'Sunday' + : '') " > {{ day.isCurrentMonth ? day.date : '' }} @@ -343,10 +354,15 @@ @if (month.holidays > 0) { {{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }} } - {{ month.sundays }} Sun + @if (month.sundays > 0) { + {{ month.sundays }} Sun + } @if (month.secondFourthSat > 0) { {{ month.secondFourthSat }} Sat Off } + @if (month.weeklyOff > 0) { + {{ month.weeklyOff }} Weekly Off + }
} diff --git a/src/app/bank-calendar/bank-calendar.component.ts b/src/app/bank-calendar/bank-calendar.component.ts index bfcacb185..9102f10d6 100644 --- a/src/app/bank-calendar/bank-calendar.component.ts +++ b/src/app/bank-calendar/bank-calendar.component.ts @@ -63,6 +63,8 @@ interface CalendarDay { isSunday: boolean; is2ndSaturday: boolean; is4thSaturday: boolean; + /** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */ + isWeeklyOff: boolean; holidayName?: string; dayOfWeek: number; // 0=Sun, 6=Sat } @@ -76,9 +78,23 @@ interface MonthData { nonWorkingDays: number; holidays: number; sundays: number; + /** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */ secondFourthSat: number; + /** Other weekdays off every week because Working Days leaves them out. */ + weeklyOff: number; } +/** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */ +const DAY_CODES = [ + 'SU', + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA' +]; + @Component({ selector: 'mifosx-bank-calendar', templateUrl: './bank-calendar.component.html', @@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit { ]; workingDaysConfig: string[] = []; holidays: any[] = []; - overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 }; // ───── Holiday list (office-wise) ───── offices: OfficeLite[] = []; @@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit { } private loadWorkingDays() { - // Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off) + // Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off), used until + // Admin > Working Days answers, or if it cannot be read. this.workingDaysConfig = [ 'MO', 'TU', @@ -215,6 +232,19 @@ export class BankCalendarComponent implements OnInit { this.buildCalendar(); this.rebuildHolidayRows(); + // The calendar has to agree with Admin > Working Days: a weekday unticked there is off every + // week here too. + this.organizationService.getWorkingDays().subscribe({ + next: (data: any) => { + const configured = this.parseWorkingDays(data?.recurrence); + if (configured.length > 0) { + this.workingDaysConfig = configured; + this.buildCalendar(); + } + }, + error: () => {} + }); + // Pull offices then fan-out a per-office holiday fetch so each office's // calendar entries are surfaced in the list panel (calendar grid still // unions everything for the year-at-a-glance view). @@ -273,6 +303,23 @@ export class BankCalendarComponent implements OnInit { }); } + /** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */ + private parseWorkingDays(recurrence: string | undefined): string[] { + const byDay = /BYDAY=([^;]*)/.exec(recurrence || ''); + if (!byDay) { + return []; + } + return byDay[1] + .split(',') + .map((d) => d.trim().toUpperCase()) + .filter((d) => DAY_CODES.includes(d)); + } + + /** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */ + get saturdayIsWorkingDay(): boolean { + return this.workingDaysConfig.includes('SA'); + } + /** Flatten the holidays array into a presentable list with office labels. */ private rebuildHolidayRows(): void { this.holidayRows = this.holidays.map((h: any) => { @@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit { buildCalendar() { this.months = []; - this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + this.overallStats = { + working: 0, + nonWorking: 0, + holidays: 0, + sundays: 0, + sat2nd4th: 0, + weeklyOff: 0, + totalDays: 0 + }; for (let m = 0; m < 12; m++) { const monthData = this.buildMonth(m, this.currentYear); @@ -521,6 +576,7 @@ export class BankCalendarComponent implements OnInit { this.overallStats.holidays += monthData.holidays; this.overallStats.sundays += monthData.sundays; this.overallStats.sat2nd4th += monthData.secondFourthSat; + this.overallStats.weeklyOff += monthData.weeklyOff; } this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking; } @@ -552,6 +608,7 @@ export class BankCalendarComponent implements OnInit { let holidayCount = 0; let sundayCount = 0; let sat2nd4thCount = 0; + let weeklyOffCount = 0; // Previous month padding const prevMonthDays = new Date(year, month, 0).getDate(); @@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit { isSunday: false, is2ndSaturday: false, is4thSaturday: false, + isWeeklyOff: false, dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7 }); } @@ -576,24 +634,29 @@ export class BankCalendarComponent implements OnInit { for (let d = 1; d <= daysInMonth; d++) { const date = new Date(year, month, d); const dow = date.getDay(); - const isSunday = dow === 0; const isSaturday = dow === 6; + const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]); + const isSunday = dow === 0 && !isWorkingWeekday; if (isSaturday) saturdayCount++; - const is2ndSat = isSaturday && saturdayCount === 2; - const is4thSat = isSaturday && saturdayCount === 4; + // RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves + // Saturday out, every Saturday is a weekly off instead. + const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2; + const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4; + const isWeeklyOff = dow !== 0 && !isWorkingWeekday; const isToday = date.toDateString() === today.toDateString(); // Check if holiday const holiday = this.getHolidayForDate(year, month, d); const isHoliday = !!holiday; - // Non-working: Sunday, 2nd Sat, 4th Sat, or holiday - const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday; + // Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday + const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday; if (isSunday) sundayCount++; - if (is2ndSat || is4thSat) sat2nd4thCount++; + if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++; + if (isWeeklyOff && !isSaturday) weeklyOffCount++; if (isHoliday) holidayCount++; if (isNonWorking) { @@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit { isSunday, is2ndSaturday: is2ndSat, is4thSaturday: is4thSat, + isWeeklyOff, holidayName: holiday?.name, dayOfWeek: dow }); @@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit { isSunday: false, is2ndSaturday: false, is4thSaturday: false, + isWeeklyOff: false, dayOfWeek: days.length % 7 }); } @@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit { nonWorkingDays: nonWorkingCount, holidays: holidayCount, sundays: sundayCount, - secondFourthSat: sat2nd4thCount + secondFourthSat: sat2nd4thCount, + weeklyOff: weeklyOffCount }; } From 915deddb74e8dc4543f11bd54ee58ca532d7dd88 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:41:24 +0530 Subject: [PATCH 20/21] feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position - Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card, denomination-wise validation against the master and real-time impact preview. - Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and denomination holdings shown, blocked until both are allocated cash. - Reconciliation, exchange and inter-cashier operate on the logged-in cashier only. - New Branch Cash Position screen (vault + cashier drawers, denomination-wise). - Customer search dialog shows the KYC PAN. - Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row. - Reverse actions removed from cash transfer, settlement, exchange and inter-cashier. Co-Authored-By: Claude Sonnet 5.5 --- .../branch-cash-position.component.html | 114 +++ .../branch-cash-position.component.scss | 82 ++ .../branch-cash-position.component.ts | 112 +++ .../cash-exchange-entry.component.html | 355 +++++++-- .../cash-exchange-entry.component.scss | 720 ++++++++++++++++-- .../cash-exchange-entry.component.ts | 281 ++++++- .../cash-history/cash-history.component.html | 27 +- .../cash-history/cash-history.component.scss | 32 - .../cash-history/cash-history.component.ts | 61 +- src/app/cashier/cashier-routing.module.ts | 8 + src/app/cashier/cashier.service.ts | 25 +- .../inter-cashier-transfer.component.html | 163 +++- .../inter-cashier-transfer.component.scss | 150 ++++ .../inter-cashier-transfer.component.ts | 168 +++- .../reconciliation.component.html | 39 +- .../reconciliation.component.ts | 63 +- src/app/core/shell/shell.component.html | 46 +- src/app/core/shell/shell.component.scss | 38 + .../customer-search-dialog.component.ts | 657 ++++++++++++---- 19 files changed, 2601 insertions(+), 540 deletions(-) create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.html create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.scss create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.ts create mode 100644 src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.scss diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.html b/src/app/cashier/branch-cash-position/branch-cash-position.component.html new file mode 100644 index 000000000..d8f476a2e --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.html @@ -0,0 +1,114 @@ + + +
+
+
+
BRANCH OPERATIONS · BRANCH CASH POSITION
+

Branch Cash Position

+

+ The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash + moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it. +

+
+
+ +
+ + As of Date + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (loading) { +
Loading…
+ } + + @if (position; as p) { +
+
+ Branch{{ p.officeName }} +
+
+ Vault₹{{ p.vaultAmount | number: '1.2-2' }} +
+
+ Cashiers' drawers₹{{ p.cashiersAmount | number: '1.2-2' }} +
+
+ Bank total₹{{ p.totalAmount | number: '1.2-2' }} +
+
+ + @if (!p.vaultHasBreakdown) { +
+ No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open + the business day with a note/coin count to start tracking it. +
+ } + + + @if (rows.length > 0) { + + + + + + + + + + + + + + @for (d of rows; track d.denominationId) { + + + + + + + + + } + +
DenominationVaultCashiersBank total (count)Bank total (₹)Exchange today
+ ₹{{ d.denominationValue }} + {{ d.vaultCount }}{{ d.cashierCount }}{{ d.totalCount }}₹{{ d.totalAmount | number: '1.2-2' }} + {{ movement(d.exchangeNet) }} +
+ } +
+ +
+ + +
+ +

+ Exchange today is the notes taken in less the notes given out by cash exchanges on + {{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix + can be seen. +

+ } +
diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.scss b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss new file mode 100644 index 000000000..d86edfbbc --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss @@ -0,0 +1,82 @@ +/** + * 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/. + */ + +.bcp-error { + color: #dc2626; +} + +.bcp-note { + margin-bottom: 12px; + text-align: left; +} + +.bcp-totals { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; +} + +.bcp-total { + flex: 1 1 180px; + padding: 12px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + + span { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + strong { + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + + &--main { + background: #e6f4ee; + border-color: #1f7a4d; + + strong { + color: #0f4f3c; + } + } +} + +.bcp-card { + margin-bottom: 14px; +} + +.bcp-strong { + font-weight: 700; +} + +.bcp-move { + font-weight: 700; + color: #94a3b8; + + &--in { + color: #16a34a; + } + + &--out { + color: #dc2626; + } +} + +.bcp-foot { + margin: 0; + font-size: 12px; + color: #64748b; +} diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.ts b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts new file mode 100644 index 000000000..ef5e64330 --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts @@ -0,0 +1,112 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; + +interface BranchDenomRow { + denominationId: number; + denominationValue: number; + kind: string; + vaultCount: number; + cashierCount: number; + totalCount: number; + totalAmount: number; + /** Notes taken in less notes given out by exchanges on the day. */ + exchangeNet: number; +} + +interface BranchPosition { + officeId: number; + officeName: string; + asOfDate: string; + vaultHasBreakdown: boolean; + vaultAmount: number; + cashiersAmount: number; + totalAmount: number; + denominations: BranchDenomRow[]; +} + +/** + * What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange + * (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so + * it only shows up in this total -- +10 on ₹500, -50 on ₹100. + */ +@Component({ + selector: 'mifosx-branch-cash-position', + templateUrl: './branch-cash-position.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../cashier-position/cashier-position.component.scss', + './branch-cash-position.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule + ] +}) +export class BranchCashPositionComponent implements OnInit { + private service = inject(CashierService); + + asOfDate = ''; + loading = false; + errorMsg = ''; + position: BranchPosition | null = null; + + ngOnInit(): void { + const d = businessToday(); + this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.position = null; + this.service + .branchCashPosition(this.asOfDate) + .pipe(catchError(() => of(null))) + .subscribe((data: BranchPosition | null) => { + this.loading = false; + if (!data) { + this.errorMsg = 'Could not load the branch cash position.'; + return; + } + this.position = data; + }); + } + + onDateChange(value: string): void { + this.asOfDate = value; + this.load(); + } + + notes(): BranchDenomRow[] { + return (this.position?.denominations || []).filter((d) => d.kind !== 'coin'); + } + + coins(): BranchDenomRow[] { + return (this.position?.denominations || []).filter((d) => d.kind === 'coin'); + } + + /** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */ + movement(net: number): string { + if (!net) return '—'; + return net > 0 ? `+${net}` : `${net}`; + } +} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html index e08dc73b0..d46a2d0d6 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html @@ -22,50 +22,255 @@
- -
-
- {{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }} - Account Holder -
- - @if (isAccountHolder) { - - } @else { - person_outline Walk-in Customer - } - - @if (needsIdProof) { -
- warning - Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI) + + @if (cashier) { +
+
{{ cashierInitials }}
+
+
Cashier at the counter
+
{{ cashier.name || '—' }}
+
+ south Receives from customer + north Gives to customer +
- - } -
+
+
+ Counter{{ cashier.tellerName || '—' }} +
+
+ Branch{{ cashier.officeName || '—' }} +
+
+ Allocated today{{ + cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2') + }} +
+
+ Cash in hand₹{{ cashier.totalAmount | number: '1.2-2' }} +
+
+ + } @else if (noCashier) { +
+ person_off +
+
Your login is not linked to a cashier
+
+ A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in + as a cashier to continue. +
+
+
+ } + + +
+
+
+ groups +
+
Customer
+
Who is exchanging cash at the counter
+
+
+
+ + +
+
+ +
+ +
+ @if (customerMode === 'existing') { + @if (loadingCustomer) { +
+ autorenew +
Loading customer details…
+
+ } @else if (!selectedCustomer) { +
+ person_search +
No customer selected
+
Search by name, account no., PAN or Aadhaar
+ +
+ } @else { +
+
+
{{ customerInitials }}
+
+
{{ selectedCustomer.displayName }}
+
+ + {{ + selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe' + }} + KYC: {{ selectedCustomer.kycStatus }} + + @if (selectedCustomer.accountType) { + {{ selectedCustomer.accountType }} + } +
+
+
+ + +
+
+
+
+ CIF No.{{ selectedCustomer.cifNumber || '—' }} +
+
+ Client ID{{ selectedCustomer.clientId }} +
+
+ Mobile{{ selectedCustomer.mobileNo || '—' }} +
+
+ Branch{{ selectedCustomer.officeName || '—' }} +
+
+ Account{{ selectedCustomer.accountNo || 'No account' }} +
+
+ PAN on record + + {{ selectedCustomer.kycPan || 'Not on record' }} + @if (selectedCustomer.kycPan) { + {{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }} + } + +
+
+
+ } + } @else { +
+ + +
+ } +
+ + +
+
+ credit_card + PAN Card + {{ needsPan ? 'Mandatory' : 'Optional' }} +
+
+ + @if (panValue && !panFormatInvalid) { + check_circle + } @else if (panFormatInvalid) { + error + } +
+
+ @if (panFormatInvalid) { + PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F). + } @else if (panFromKyc) { + link Fetched from the customer's KYC record. + } @else if (panMissing) { + Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI). + } @else { + Mandatory for exchanges above ₹50,000. + } +
+
+
+
+ + + @if (availableFor(d) === null) { + — + } @else { + In drawer {{ availableFor(d) }} + @if (netFor(d) !== 0) { + + {{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }} + {{ drawerAfter(d) }} + ({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }}) + + } + } + +
@@ -78,7 +283,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -90,6 +295,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -99,7 +307,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -111,6 +319,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -131,7 +342,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -141,8 +352,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -152,7 +371,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -162,8 +381,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -173,6 +400,30 @@
+ + @if (impactRows.length) { +
+
inventory_2 Drawer after this exchange
+
+ @for (r of impactRows; track r.id) { + + {{ r.label }} + {{ r.before }} → {{ r.after }} + ({{ r.net > 0 ? '+' : '' }}{{ r.net }}) + + } +
+
+ The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch + Cash Position). +
+
+ } +
Received₹{{ receivedTotal | number: '1.2-2' }} @@ -213,9 +464,15 @@
} - @if (needsIdProof && !customerIdProof.trim()) { + @if (panMissing) {
- badge Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000). + badge Enter the customer's PAN to proceed (amount above ₹50,000). +
+ } + @if (!customerIdentified && !loadingCustomer) { +
+ person_search + {{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss index 9c4277912..88903f1a0 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss @@ -73,99 +73,584 @@ } } -/* ── Customer info bar ── */ -.cx-customer-bar { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: #f8fafc; +/* ── Customer: existing client or walk-in, plus PAN ── */ +.cx-cust { + background: #fff; border: 1px solid #e2e8f0; - border-radius: 10px; - flex-wrap: wrap; -} + border-radius: 14px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + overflow: hidden; -.cx-customer-toggle { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 600; - color: #475569; - cursor: pointer; - user-select: none; + &__head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 12px 16px; + background: linear-gradient(180deg, #f8fafc, #f1f5f9); + border-bottom: 1px solid #e2e8f0; + } - mat-icon { - font-size: 20px; - height: 20px; - width: 20px; + &__title { + display: flex; + align-items: center; + gap: 10px; + } + + &__title-icon { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: #e6f4ee; + color: #0f4f3c; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + &__title-text { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__title-sub { + font-size: 12px; + color: #64748b; + } + + &__modes { + display: inline-flex; + padding: 3px; + background: #e2e8f0; + border-radius: 10px; + } + + &__mode { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border: 0; + border-radius: 8px; + background: transparent; + font-size: 13px; + font-weight: 600; + color: #475569; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + color: #0f172a; + } + + &--active, + &--active:hover { + background: #fff; + color: #0f4f3c; + box-shadow: 0 1px 3px rgb(15 23 42 / 15%); + } + } + + &__body { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); + gap: 16px; + padding: 16px; + } + + &__who { + min-width: 0; + } + + /* Nothing picked yet, or loading */ + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 150px; + padding: 20px; + text-align: center; + background: #f8fafc; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + } + + &__empty-icon { + font-size: 36px; + width: 36px; + height: 36px; color: #94a3b8; } - &--active { - color: #1e40af; + &__empty-title { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + &__empty-sub { + margin-bottom: 8px; + font-size: 12px; + color: #64748b; + } + + /* A picked client */ + &__card { + padding: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + } + + &__card-top { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + } + + &__card-id { + flex: 1 1 180px; + min-width: 0; + } + + &__name { + font-size: 16px; + font-weight: 700; + color: #0f172a; + overflow-wrap: anywhere; + } + + &__badges { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + } + + &__card-actions { + display: flex; + gap: 4px; + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px 16px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed #e2e8f0; + + span { + display: block; + margin-bottom: 2px; + font-size: 11px; + color: #64748b; + } + + strong { + font-size: 13px; + font-weight: 600; + color: #0f172a; + } + } + + /* A walk-in, identified at the counter */ + &__walkin { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + + .cx-field { + font-size: 12px; + } + + .cx-field input { + width: 100%; + height: 40px; + box-sizing: border-box; + } + } + + .cx-field input:focus { + outline: none; + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 15%); + } +} + +.cx-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #1f7a4d, #0f4f3c); + font-size: 15px; + font-weight: 700; + letter-spacing: 0.02em; + color: #fff; + + &--sm { + width: 40px; + height: 40px; + font-size: 14px; + } +} + +/* The cashier at the counter, or why there is none */ +.cx-cashier { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px 20px; + padding: 12px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #1f7a4d; + border-radius: 14px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + + &__id { + flex: 1 1 220px; + min-width: 0; + } + + &__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + &__name { + font-size: 16px; + font-weight: 700; + color: #0f172a; + } + + &__flow { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 2px; + font-size: 12px; + color: #475569; + + span { + display: inline-flex; + align-items: center; + gap: 2px; + } mat-icon { - color: #1e40af; + font-size: 14px; + width: 14px; + height: 14px; + color: #94a3b8; + } + } + + &__stats { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; + + span { + display: block; + font-size: 11px; + color: #64748b; + } + + strong { + font-size: 14px; + font-weight: 600; + color: #0f172a; + } + } + + &--none { + background: #fffbeb; + border-color: #fcd34d; + border-left-color: #d97706; + + mat-icon { + color: #d97706; + } + + .cx-cashier__label { + font-weight: 500; + letter-spacing: 0; + text-transform: none; + color: #92400e; } } } -.cx-walk-in { - display: flex; +.cx-kyc { + display: inline-flex; align-items: center; gap: 4px; - font-size: 12px; - color: #64748b; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; mat-icon { - font-size: 16px; - height: 16px; - width: 16px; + font-size: 14px; + width: 14px; + height: 14px; + } + + &--ok { + background: #dcfce7; + color: #166534; + } + + &--warn { + background: #fef3c7; + color: #92400e; + } + + &--sm { + margin-left: 6px; + padding: 1px 6px; + font-size: 10px; } } -.cx-pan-required { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: #fffbeb; - border: 1px solid #fcd34d; +.cx-chip-tag { + padding: 2px 8px; border-radius: 6px; + background: #eef2ff; + font-size: 11px; + font-weight: 600; + color: #3730a3; +} + +.cx-link-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; font-size: 12px; font-weight: 600; - color: #92400e; + color: #1e40af; + cursor: pointer; mat-icon { font-size: 16px; - height: 16px; width: 16px; - color: #d97706; + height: 16px; + } + + &:hover { + background: #eff6ff; + } + + &--danger { + color: #b91c1c; + + &:hover { + background: #fef2f2; + } } } -.cx-field--inline { - flex-direction: row; - align-items: center; +/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */ +.cx-pan { + display: flex; + flex-direction: column; + align-self: start; gap: 8px; + padding: 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; - span { - white-space: nowrap; + &__head { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #475569; + } } - input { - width: 180px; + &__label { + font-size: 13px; + font-weight: 700; + color: #0f172a; + } + + &__tag { + margin-left: auto; + padding: 2px 8px; + border-radius: 999px; + background: #e2e8f0; + font-size: 11px; + font-weight: 600; + color: #475569; + + &--on { + background: #fef3c7; + color: #92400e; + } + } + + &__input { + position: relative; + + input { + width: 100%; + height: 44px; + box-sizing: border-box; + padding: 0 40px 0 12px; + background: #fff; + border: 1.5px solid #cbd5e1; + border-radius: 10px; + font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace; + font-size: 18px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #0f172a; + + &::placeholder { + font-weight: 500; + color: #cbd5e1; + } + + &:focus { + outline: none; + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 15%); + } + } + } + + &__state { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + font-size: 22px; + width: 22px; + height: 22px; + + &--ok { + color: #16a34a; + } + + &--bad { + color: #dc2626; + } + } + + &__hint { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #64748b; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + + &--required { + background: #fffbeb; + border-color: #fcd34d; + + .cx-pan__input input { + border-color: #f59e0b; + } + + .cx-pan__hint { + font-weight: 600; + color: #92400e; + } + } + + &--invalid { + background: #fef2f2; + border-color: #fecaca; + + .cx-pan__input input { + border-color: #ef4444; + } + + .cx-pan__hint { + font-weight: 600; + color: #b91c1c; + } + } + + &--valid { + background: #f0fdf4; + border-color: #bbf7d0; + + .cx-pan__input input { + border-color: #22c55e; + } + + .cx-pan__hint { + color: #166534; + } } } -.cx-field--pan input { - width: 160px; - border-color: #fcd34d; +.cx-spin { + animation: cx-spin 1s linear infinite; +} - &:focus { - outline: none; - border-color: #d97706; +@keyframes cx-spin { + to { + transform: rotate(360deg); + } +} + +@media (width <= 900px) { + .cx-cust__body { + grid-template-columns: 1fr; + } +} + +@media (width <= 600px) { + .cx-cust__walkin { + grid-template-columns: 1fr; } } @@ -345,6 +830,129 @@ border-bottom: 1px dotted #eef2f7; } +/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */ +.cx-row--stock { + grid-template-columns: 64px 120px 1fr 96px; +} + +.cx-row--over { + background: #fef2f2; +} + +.cx-avail { + padding-left: 6px; + font-size: 12px; + color: #64748b; + + strong { + font-weight: 700; + color: #0f172a; + } + + /* the count after this exchange: green when it goes up, red when it goes down */ + .cx-after { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 6px; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + small { + font-size: 11px; + } + } + + &--up .cx-after, + &--up .cx-after strong { + color: #15803d; + } + + &--down .cx-after, + &--down .cx-after strong { + color: #b45309; + } + + &--over, + &--over strong, + &--over .cx-after, + &--over .cx-after strong { + color: #b91c1c; + } +} + +/* The drawer after the whole exchange, one chip per denomination it changes */ +.cx-impact { + padding: 12px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + + &__title { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 13px; + font-weight: 700; + color: #0f172a; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #475569; + } + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 8px; + background: #f1f5f9; + font-size: 12px; + color: #334155; + + b { + font-weight: 700; + } + + em { + font-style: normal; + font-weight: 700; + } + + &--up { + background: #dcfce7; + color: #166534; + } + + &--down { + background: #fef3c7; + color: #92400e; + } + } + + &__note { + margin-top: 8px; + font-size: 11px; + color: #64748b; + } +} + .cx-chip { display: inline-flex; align-items: center; diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts index bede34b4b..98916e2bc 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts @@ -11,7 +11,15 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { + CustomerSearchDialogComponent, + CustomerSearchResult +} from 'app/shared/customer-search-dialog/customer-search-dialog.component'; import { businessToday } from 'app/core/utils/business-today'; interface Denom { @@ -21,6 +29,47 @@ interface Denom { label: string; } +/** An existing client picked through Customer Search, with what the counter needs to identify them. */ +interface SelectedCustomer { + clientId: number; + displayName: string; + cifNumber: string | null; + mobileNo: string | null; + officeName: string | null; + accountNo: string | null; + accountType: string | null; + /** PAN on the client's KYC record, if any. */ + kycPan: string | null; + panVerified: boolean; + kycStatus: string; +} + +/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */ +interface CashierInfo { + cashierId: number; + name: string; + tellerName: string | null; + officeName: string | null; + /** Cash the vault allocated to them for the day, once known. */ + allocatedAmount: number | null; + /** Cash they hold now. */ + totalAmount: number; +} + +/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */ +const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/; + +/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */ +const PAN_MANDATORY_ABOVE = 50000; + +const KYC_STATUS: Record = { + 100: 'Pending', + 200: 'In Progress', + 300: 'Verified', + 400: 'Rejected', + 500: 'Expired' +}; + /** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */ @Component({ selector: 'mifosx-cash-exchange-entry', @@ -31,11 +80,14 @@ interface Denom { CommonModule, FormsModule, MatIconModule, - MatSnackBarModule + MatSnackBarModule, + MatDialogModule ] }) export class CashExchangeEntryComponent implements OnInit { private service = inject(CashierService); + private clientsService = inject(ClientsService); + private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); notes: Denom[] = []; @@ -47,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit { saving = false; // ── Customer info ── - isAccountHolder = false; - accountNo = ''; - customerIdProof = ''; + /** An existing client found through Customer Search, or a walk-in identified at the counter. */ + customerMode: 'existing' | 'walkin' = 'walkin'; + selectedCustomer: SelectedCustomer | null = null; + loadingCustomer = false; + walkInName = ''; + walkInMobile = ''; + panNumber = ''; - // ── Cashier position (for denomination overdraft check) ── + // ── Cashier at the counter, and their position (for the denomination overdraft check) ── + /** The cashier linked to the logged-in user; null when the login is not a cashier's. */ + cashier: CashierInfo | null = null; private availableMap = new Map(); positionLoaded = false; @@ -88,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit { ((res?.denominations || []) as any[]).forEach((d: any) => { if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0)); }); + const cashierId = res?.cashierId ? Number(res.cashierId) : null; + this.cashier = cashierId + ? { + cashierId, + name: res.cashierName || '', + tellerName: null, + officeName: null, + allocatedAmount: null, + totalAmount: Number(res.totalAmount ?? 0) + } + : null; this.positionLoaded = true; + if (cashierId) this.loadCashierDetails(cashierId); }, error: () => { this.positionLoaded = true; @@ -96,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit { }); } + /** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */ + private loadCashierDetails(cashierId: number): void { + this.service + .getInterCashierCashierSummary(cashierId, this.exchangeDate) + .pipe(catchError(() => of(null))) + .subscribe((r: any) => { + if (!r || this.cashier?.cashierId !== cashierId) return; + this.cashier = { + ...this.cashier, + tellerName: r.tellerName || null, + officeName: r.officeName || null, + allocatedAmount: Number(r.allocatedAmount ?? 0) + }; + }); + } + + /** Up to two letters for the cashier's avatar. */ + get cashierInitials(): string { + const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return '?'; + const first = words[0].charAt(0); + const last = words.length > 1 ? words[words.length - 1].charAt(0) : ''; + return (first + last).toUpperCase(); + } + countFor(side: 'r' | 'g', d: Denom): number { return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0; } @@ -129,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit { return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal; } - /** Above ₹50,000 PAN/ID is mandatory per RBI guideline */ - get needsIdProof(): boolean { - return this.receivedTotal >= 50000; + /** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */ + get needsPan(): boolean { + return this.receivedTotal > PAN_MANDATORY_ABOVE; + } + + /** Up to two letters for the avatar: first and last word of the customer's name. */ + get customerInitials(): string { + const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return '?'; + const first = words[0].charAt(0); + const last = words.length > 1 ? words[words.length - 1].charAt(0) : ''; + return (first + last).toUpperCase(); + } + + get panValue(): string { + return this.panNumber.trim().toUpperCase(); + } + + /** A PAN that is entered must be well-formed, required or not. */ + get panFormatInvalid(): boolean { + return !!this.panValue && !PAN_PATTERN.test(this.panValue); + } + + get panMissing(): boolean { + return this.needsPan && !this.panValue; + } + + /** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */ + get panFromKyc(): boolean { + return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase(); + } + + /** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */ + get customerIdentified(): boolean { + if (this.customerMode === 'existing') return !!this.selectedCustomer; + return !this.needsPan || this.walkInName.trim().length > 0; + } + + /** A login with no cashier behind it has no drawer to take notes into or give change from. */ + get noCashier(): boolean { + return this.positionLoaded && !this.cashier; + } + + /** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */ + availableFor(d: Denom): number | null { + if (!this.positionLoaded || !this.cashier) return null; + return this.availableMap.get(d.id) ?? 0; + } + + /** The cashier is giving more of this denomination than they hold. */ + exceedsAvailable(d: Denom): boolean { + const available = this.availableFor(d); + return available !== null && this.countFor('g', d) > available; + } + + /** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */ + canGiveMore(d: Denom): boolean { + const available = this.availableFor(d); + return available === null || this.countFor('g', d) < available; + } + + /** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */ + netFor(d: Denom): number { + return this.countFor('r', d) - this.countFor('g', d); + } + + /** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */ + drawerAfter(d: Denom): number | null { + const available = this.availableFor(d); + return available === null ? null : available + this.netFor(d); + } + + /** + * The denominations this exchange changes, with the drawer's count before and after, so the effect shows + * while the counts are being typed. The branch's total for each denomination moves by the same amount, as + * the customer's notes go into the drawer and the change comes out of it; the vault is not involved. + */ + get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> { + if (!this.positionLoaded || !this.cashier) return []; + return this.denoms + .filter((d) => this.netFor(d) !== 0) + .map((d) => ({ + id: d.id, + label: d.label, + kind: d.kind, + net: this.netFor(d), + before: this.availableFor(d) ?? 0, + after: this.drawerAfter(d) ?? 0 + })); } /** Denominations where cashier is giving more than currently available. */ get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> { - if (!this.positionLoaded) return []; + // Without a cashier every denomination would read as "0 available"; the missing cashier is the + // thing to tell the user, not a stock shortage. + if (!this.positionLoaded || !this.cashier) return []; return this.denoms .map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) })) .filter((item) => item.requested > 0 && item.requested > item.available); @@ -147,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit { } get valid(): boolean { - return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft; + return ( + this.matches && + !!this.cashier && + !this.hasGivenOverdraft && + !this.panFormatInvalid && + !this.panMissing && + this.customerIdentified && + !this.loadingCustomer + ); } - toggleAccountHolder(): void { - this.isAccountHolder = !this.isAccountHolder; - if (!this.isAccountHolder) this.accountNo = ''; + setCustomerMode(mode: 'existing' | 'walkin'): void { + if (this.customerMode === mode) return; + this.customerMode = mode; + this.selectedCustomer = null; + this.walkInName = ''; + this.walkInMobile = ''; + this.panNumber = ''; + } + + /** Pick an existing client through Customer Search, then load their details and KYC (PAN). */ + openCustomerSearch(): void { + this.dialog + // An existing customer without an account is still a known customer, so the account is not required. + .open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } }) + .afterClosed() + .subscribe((result: CustomerSearchResult | undefined) => { + if (!result?.clientId) return; + this.loadCustomer(result); + }); + } + + private loadCustomer(result: CustomerSearchResult): void { + this.loadingCustomer = true; + const id = String(result.clientId); + forkJoin({ + client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))), + kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null))) + }).subscribe(({ client, kyc }: { client: any; kyc: any }) => { + const kycPan: string | null = kyc?.panNumber || result.panNumber || null; + this.selectedCustomer = { + clientId: result.clientId, + displayName: client?.displayName || result.displayName, + cifNumber: client?.cifNumber || null, + mobileNo: client?.mobileNo || result.mobileNo || null, + officeName: client?.officeName || result.officeName || null, + accountNo: result.accountNo || null, + accountType: result.accountType || null, + kycPan, + panVerified: !!kyc?.panVerified, + kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record' + }; + this.panNumber = kycPan ? kycPan.toUpperCase() : ''; + this.loadingCustomer = false; + }); + } + + clearCustomer(): void { + this.selectedCustomer = null; + this.panNumber = ''; } clear(): void { this.receivedCounts = {}; this.givenCounts = {}; this.note = ''; - this.accountNo = ''; - this.customerIdProof = ''; - this.isAccountHolder = false; + this.customerMode = 'walkin'; + this.selectedCustomer = null; + this.walkInName = ''; + this.walkInMobile = ''; + this.panNumber = ''; } submit(): void { @@ -171,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit { this.denoms .filter((d) => this.countFor(side, d) > 0) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) })); + const customer = this.customerMode === 'existing' ? this.selectedCustomer : null; + const pan = this.panValue || null; const payload: any = { exchangeDate: this.exchangeDate, note: this.note || null, - customerIdProof: this.customerIdProof.trim() || null, - accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null, + clientId: customer?.clientId ?? null, + accountNo: customer?.accountNo ?? null, + customerName: customer ? customer.displayName : this.walkInName.trim() || null, + customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null, + panNumber: pan, + customerIdProof: pan, received: build('r'), given: build('g') }; diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html index 4e85528dd..943ff9323 100644 --- a/src/app/cashier/cash-history/cash-history.component.html +++ b/src/app/cashier/cash-history/cash-history.component.html @@ -11,25 +11,11 @@
CASHIER · HISTORY

Cash Movement History

-

All transfers, settlements, and inter-cashier movements. Reverse any active transaction.

+

All transfers, settlements, inter-cashier movements and cash exchanges.

- - @if (reversingId !== null) { -
- warning - Reverse #{{ reversingId }}? This cannot be undone. - - - -
- } - @if (loading) {
Loading…
} @@ -134,11 +120,6 @@ > visibility - @if (!r.isReversed) { - - }
@@ -187,6 +168,12 @@ Date {{ detail.row.date }}
+ @if (detail.row.type === 'exchange') { +
+ Customer PAN + {{ detail.row.panNumber || '—' }} +
+ }
Total Amount ₹{{ detail.row.amount | number: '1.2-2' }} diff --git a/src/app/cashier/cash-history/cash-history.component.scss b/src/app/cashier/cash-history/cash-history.component.scss index 1db02c17d..17ae05de0 100644 --- a/src/app/cashier/cash-history/cash-history.component.scss +++ b/src/app/cashier/cash-history/cash-history.component.scss @@ -47,38 +47,6 @@ } } -.reverse-panel { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: #fffbeb; - border: 1px solid #fcd34d; - border-radius: 10px; - flex-wrap: wrap; - font-size: 13px; - color: #92400e; - - .reverse-icon { - color: #f59e0b; - font-size: 20px; - height: 20px; - width: 20px; - } - - .reverse-reason { - flex: 1 1 200px; - height: 34px; - padding: 0 10px; - border: 1px solid #fcd34d; - border-radius: 7px; - font-size: 13px; - font-family: inherit; - background: #fff; - color: #1a1a1a; - } -} - /* ── Action buttons cell ── */ .action-btns { display: flex; diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts index 6f49e8d53..281513c46 100644 --- a/src/app/cashier/cash-history/cash-history.component.ts +++ b/src/app/cashier/cash-history/cash-history.component.ts @@ -8,9 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { forkJoin, of } from 'rxjs'; @@ -31,6 +29,8 @@ interface TxnRow { reversedBy: number | null; reversedAt: string | null; reverseReason: string | null; + /** Customer PAN recorded on a cash exchange. */ + panNumber?: string | null; type: 'transfer' | 'settlement' | 'inter' | 'exchange'; } @@ -52,7 +52,10 @@ interface DetailView { loading: boolean; } -/** Unified history of all cash movements with reversal and detail view support. */ +/** + * Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed + * from here (an earlier reversal still shows as Reversed, with who, when and why). + */ @Component({ selector: 'mifosx-cash-history', templateUrl: './cash-history.component.html', @@ -63,16 +66,13 @@ interface DetailView { standalone: true, imports: [ CommonModule, - FormsModule, MatIconModule, - MatSnackBarModule, MatTabsModule, MatPaginatorModule ] }) export class CashHistoryComponent implements OnInit { private service = inject(CashierService); - private snackBar = inject(MatSnackBar); transfers: TxnRow[] = []; settlements: TxnRow[] = []; @@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit { private denomKindMap = new Map(); - reversingId: number | null = null; - reversingType: string | null = null; - reverseReason = ''; - detail: DetailView | null = null; ngOnInit(): void { @@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit { this.exchanges = (exchangeItems as any[]).map((e) => ({ id: e.id, date: e.exchangeDate, - fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + fromParty: this.exchangeParty(e), + panNumber: e.panNumber ?? null, toParty: e.performedByUsername || '—', amount: e.exchangeAmount, note: e.note, @@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit { this.exchanges = (res.pageItems ?? []).map((e: any) => ({ id: e.id, date: e.exchangeDate, - fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + fromParty: this.exchangeParty(e), + panNumber: e.panNumber ?? null, toParty: e.performedByUsername || '—', amount: e.exchangeAmount, note: e.note, @@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit { this.detail = null; } - startReverse(row: TxnRow): void { - this.reversingId = row.id; - this.reversingType = row.type; - this.reverseReason = ''; - } - - cancelReverse(): void { - this.reversingId = null; - this.reversingType = null; - this.reverseReason = ''; - } - - confirmReverse(): void { - if (!this.reversingId || !this.reversingType) return; - const id = this.reversingId; - const type = this.reversingType; - const reason = this.reverseReason; - this.cancelReverse(); - - let obs$; - if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason); - else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason); - else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason); - else obs$ = this.service.reverseInterCashierTransfer(id, reason); - - obs$.pipe(catchError(() => of(null))).subscribe((res: any) => { - if (res?.reversed) { - this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 }); - this.load(); - } else { - this.snackBar.open('Reversal failed', 'Close', { duration: 3000 }); - } - }); + /** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */ + private exchangeParty(e: any): string { + if (e.customerName) { + return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName; + } + return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer'; } typeLabel(type: string): string { diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 045252339..76947f25b 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component'; import { VoucherReportComponent } from './voucher-report/voucher-report.component'; import { BranchDayComponent } from './branch-day/branch-day.component'; import { VaultPositionComponent } from './vault-position/vault-position.component'; +import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component'; import { AccountRegisterComponent } from './account-register/account-register.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; @@ -225,6 +226,13 @@ const routes: Routes = [ component: VaultPositionComponent, data: { title: 'Vault Position', breadcrumb: 'Vault Position' } }, + { + // What the whole branch holds per denomination: the vault plus every cashier's drawer, so an + // exchange (which leaves the vault alone) is visible in the bank's total. + path: 'branch-position', + component: BranchCashPositionComponent, + data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' } + }, { // The same day book the clerk and cashier read, reached from where a principal officer // signing the day off actually looks. It carries the Officer Verification Sheet — each diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index f3700e20c..cfa8def18 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -32,9 +32,6 @@ export class CashierService { getCashExchange(id: number): Observable { return this.http.get(`/cashexchange/${id}`); } - reverseCashExchange(id: number, reason: string): Observable { - return this.http.post(`/cashexchange/${id}/reverse`, { reason }); - } // ── Tellers / Cashiers ── getTellers(officeId?: number): Observable { @@ -60,9 +57,6 @@ export class CashierService { getCashTransferById(id: number): Observable { return this.http.get(`/cashtransfer/${id}`); } - reverseCashTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashtransfer/${id}/reverse`, { reason }); - } // ── Cash Settlement ── createCashSettlement(data: any): Observable { @@ -75,9 +69,6 @@ export class CashierService { getCashSettlementById(id: number): Observable { return this.http.get(`/cashsettlement/${id}`); } - reverseCashSettlement(id: number, reason: string): Observable { - return this.http.post(`/cashsettlement/${id}/reverse`, { reason }); - } // ── Inter-cashier Transfer ── createInterCashierTransfer(data: any): Observable { @@ -90,8 +81,10 @@ export class CashierService { getInterCashierById(id: number): Observable { return this.http.get(`/cashintercashier/${id}`); } - reverseInterCashierTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashintercashier/${id}/reverse`, { reason }); + /** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */ + getInterCashierCashierSummary(cashierId: number, date: string): Observable { + const params = new HttpParams().set('cashierId', cashierId).set('date', date); + return this.http.get('/cashintercashier/cashier-summary', { params }); } // ── Reconciliation ── @@ -318,6 +311,16 @@ export class CashierService { if (asOfDate) params = params.set('asOfDate', asOfDate); return this.http.get('/cashreports/vault-position', { params }); } + /** + * Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's + * exchange movement beside it. Not for a cashier -- the server refuses it. + */ + branchCashPosition(asOfDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (asOfDate) params = params.set('asOfDate', asOfDate); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get('/cashreports/branch-position', { params }); + } excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable { let params = new HttpParams().set('threshold', String(threshold)); if (fromDate) params = params.set('fromDate', fromDate); diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html index b87a43472..45166d6e6 100644 --- a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html @@ -11,41 +11,29 @@
CASHIER · INTER-CASHIER TRANSFER

Inter-Cashier Transfer

-

Move denomination-wise cash between two cashiers within the same teller.

+

Hand over denomination-wise cash from your drawer to another cashier at your counter.

-
- @if (tellers.length === 1) { - - Teller - - - } @else { - - Teller - - @for (t of tellers; track t.id) { - {{ t.name }} - } - @if (tellers.length === 0) { - No tellers configured - } - - - } + @if (ownCashierMissing) { +
+ person_off + Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so + log in as that cashier. +
+ } +
- From (Cashier) - - @for (c of availableFrom; track c.id) { - {{ c.label }} - } - @if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) { - No cashiers - } - + Teller + + + + + + From (You) + @@ -54,7 +42,7 @@ @for (c of availableTo; track c.id) { {{ c.label }} } - @if (fromCashierId && availableTo.length === 0) { + @if (fromCashierId && !loadingCashiers && availableTo.length === 0) { No other cashiers } @@ -62,7 +50,7 @@ Date - + @@ -71,26 +59,102 @@
+ + @if (fromCashierId || toCashierId) { +
+ + arrow_forward + +
+ } + + +
+
{{ title }}
+ @if (!picked) { +
Select a cashier
+ } @else if (loading) { +
Loading…
+ } @else if (!s) { +
Could not load this cashier's details.
+ } @else { +
{{ s.cashierName || '—' }}
+
+ point_of_sale {{ s.tellerName || '—' }} + account_balance {{ s.officeName || '—' }} +
+
+
+
Allocated today
+
₹{{ s.allocatedAmount | number: '1.2-2' }}
+
+
+
Cash in hand
+
₹{{ s.totalAmount | number: '1.2-2' }}
+
+
+ @if (!s.cashAllocated) { +
+ block No cash allocated from the vault for this date — inter-cashier transfer is not + allowed. +
+ } + } +
+
+ + @if (fromSummary && toSummary && !bothAllocated) { +
+ block + Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an + inter-cashier transfer. +
+ } + @if (anyExceedsHeld) { +
+ error_outline + The sending cashier does not hold enough of the highlighted denominations. +
+ } +
+ + @if (notes.length) { - + } @for (d of notes; track d.id) { - + + + } @if (coins.length) { - + } @for (d of coins; track d.id) { - + + + } @if (denoms.length === 0) { - + }
DenominationFrom holds Count AmountTo holds
Bank NotesBank Notes
{{ d.label }} + +
@@ -99,18 +163,28 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
CoinsCoins
{{ d.label }} + +
@@ -119,17 +193,34 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ + + @if (n === null) { + — + } @else { + {{ n }} + @if (delta !== 0) { + → {{ n + delta }} + } + } + +
Total to transfer: ₹{{ total | number: '1.2-2' }} +
Verification & Reports
@@ -1166,12 +1155,9 @@
Cash & Counter
- - + @@ -1186,9 +1172,6 @@ - @@ -1209,9 +1192,6 @@ - diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index c752a9d22..95d3060f9 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -244,6 +244,44 @@ $color: #e2e4ec; } } +/* + * The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at + * the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread + * across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very + * narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was. + */ +.cashier-nav:not(.cashier-nav--clerk) { + flex-wrap: nowrap; + justify-content: space-between; + gap: 0; + padding: 4px 8px; + + .cashier-nav__item { + flex: 0 0 auto; + gap: 4px; + padding: 5px 8px; + font-size: 11.5px; + } + + @media (width <= 1240px) { + .cashier-nav__item { + padding: 5px 6px; + font-size: 11px; + } + } + + @media (width <= 1120px) { + .cashier-nav__item fa-icon { + display: none; + } + } + + @media (width <= 900px) { + flex-wrap: wrap; + justify-content: flex-start; + } +} + .sidenav { overflow-x: hidden; position: relative; diff --git a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts index 6b3aa53ce..4f47b4539 100644 --- a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts +++ b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts @@ -10,12 +10,12 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpParams } from '@angular/common/http'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; -import { MatTableModule } from '@angular/material/table'; -import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; export interface CustomerSearchResult { @@ -30,6 +30,17 @@ export interface CustomerSearchResult { address: string | null; } +/** Optional data for the dialog. */ +export interface CustomerSearchDialogData { + /** + * Whether a customer must have an account to be picked. True by default, because the account screens + * need one; a screen that only needs the customer (cash exchange) passes false. + */ + requireAccount?: boolean; +} + +type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; + @Component({ selector: 'mifosx-customer-search-dialog', standalone: true, @@ -41,173 +52,479 @@ export interface CustomerSearchResult { MatFormFieldModule, MatInputModule, MatButtonModule, - MatTableModule, - MatCheckboxModule, + MatIconModule, + MatProgressBarModule, MatSnackBarModule ], template: ` -

Search Customer

- -

- Fill in any one field, or combine several to narrow the search (e.g. name + account number). -

-
- - Name - - +
+
person_search
+
+

Search Customer

+

+ Fill in any one field, or combine several to narrow the search (e.g. name + account number). +

+
+ +
- - Account Number - - + @if (loading) { + + } - - Aadhaar Number - - + + +
+ + Name + person + + - - PAN Number - - + + Account Number + account_balance_wallet + + - - Address - - + + Aadhaar Number + fingerprint + + + + + PAN Number + credit_card + + + + + Address + place + + +
+ +
+ Account Type +
+ @for (t of accountTypeOptions; track t.key) { + + } +
+ Leave all unselected to search every account type +
- + @if (searched) { +
+
{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}
-
-

No matching customer found.

+ @if (!loading && results.length === 0) { +
+ search_off +
No matching customer found
+
Check the spelling, or try fewer fields.
+
+ } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name{{ r.displayName }}Account - {{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }}) - No account - Mobile{{ r.mobileNo || '-' }}Branch{{ r.officeName || '-' }}PAN{{ r.panNumber || '-' }}Aadhaar{{ r.aadhaarNumber || '-' }}Address{{ r.address || '-' }} - -
-
+ @for (r of results; track $index) { +
+
{{ initials(r.displayName) }}
+
+
+ {{ r.displayName }} + @if (r.accountNo) { + {{ accountTypeLabel(r.accountType) }} + {{ r.accountNo }} + } @else { + No account + } +
+
+ call{{ r.mobileNo || '—' }} + account_balance{{ r.officeName || '—' }} + credit_cardPAN {{ r.panNumber || '—' }} + fingerprintAadhaar {{ r.aadhaarNumber || '—' }} +
+ @if (r.address) { +
place{{ r.address }}
+ } +
+ +
+ } +
+ }
- - - + + + + + `, styles: [ ` - .hint-text { - color: #64748b; - font-size: 13px; - margin: 0 0 12px 0; + :host { + display: block; } - .search-form { + + .csd-head { display: flex; - flex-wrap: wrap; - gap: 12px 16px; + align-items: center; + gap: 14px; + padding: 20px 16px 12px 24px; } - .field { - flex: 1 1 220px; + + .csd-head__icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 12px; + background: #e6f4ee; + color: #0f4f3c; } - .field--wide { - flex-basis: 100%; + + .csd-head__icon mat-icon { + font-size: 26px; + width: 26px; + height: 26px; } - .account-type-filter { + + .csd-head__text { + flex: 1 1 auto; + min-width: 0; + } + + .csd-head__title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #0f172a; + } + + .csd-head__sub { + margin: 2px 0 0; + font-size: 13px; + color: #64748b; + } + + .csd-head__close { + flex: 0 0 auto; + color: #64748b; + } + + .csd-content { + padding-top: 12px; + } + + .csd-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 14px; + } + + .csd-field { + width: 100%; + } + + .csd-field--name { + grid-column: span 2; + } + + .csd-field--address { + grid-column: span 3; + } + + .csd-upper { + text-transform: uppercase; + } + + .csd-types { display: flex; align-items: center; flex-wrap: wrap; - gap: 8px 16px; - margin: 4px 0 16px 0; + gap: 8px 12px; + margin-top: 14px; } - .account-type-filter__label { + + .csd-types__label { font-size: 13px; font-weight: 600; color: #475569; } - .account-type-filter__hint { + + .csd-types__pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .csd-types__hint { font-size: 12px; color: #94a3b8; } - .results { - margin-top: 16px; - overflow-x: auto; - } - table { - width: 100%; - } - th { - font-weight: 600; + + .csd-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px 0 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 500; color: #475569; - background-color: #f8fafc; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } - td { - color: #334155; - } - .muted { + + .csd-pill mat-icon { + font-size: 18px; + width: 18px; + height: 18px; color: #94a3b8; } - .no-results { - text-align: center; + + .csd-pill:hover { + border-color: #94a3b8; + background: #f8fafc; + } + + .csd-pill--on, + .csd-pill--on:hover { + border-color: #1f7a4d; + background: #e6f4ee; + font-weight: 600; + color: #0f4f3c; + } + + .csd-pill--on mat-icon { + color: #1f7a4d; + } + + .csd-results { + margin-top: 20px; + } + + .csd-results__head { + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; color: #64748b; - padding: 16px 0; + } + + .csd-result { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 8px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + + .csd-result:hover { + border-color: #1f7a4d; + box-shadow: 0 2px 8px rgb(15 23 42 / 8%); + } + + .csd-result--noacc { + background: #f8fafc; + } + + .csd-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #1f7a4d, #0f4f3c); + font-size: 14px; + font-weight: 700; + color: #fff; + } + + .csd-result__main { + flex: 1 1 auto; + min-width: 0; + } + + .csd-result__name { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + + .csd-result__acc { + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: #475569; + } + + .csd-badge { + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + } + + .csd-badge--type { + background: #eef2ff; + color: #3730a3; + } + + .csd-badge--muted { + background: #f1f5f9; + color: #64748b; + } + + .csd-result__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 4px; + font-size: 12px; + color: #475569; + } + + .csd-result__meta span, + .csd-result__addr { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .csd-result__meta mat-icon, + .csd-result__addr mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #94a3b8; + } + + .csd-result__addr { + display: flex; + align-items: flex-start; + margin-top: 4px; + font-size: 12px; + color: #64748b; + } + + .csd-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 28px 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + text-align: center; + } + + .csd-empty mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: #94a3b8; + } + + .csd-empty__title { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + .csd-empty__sub { + font-size: 12px; + color: #64748b; + } + + .csd-actions { + gap: 8px; + padding: 12px 24px 20px; + } + + @media (max-width: 720px) { + .csd-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .csd-field--address { + grid-column: span 2; + } + + .csd-result { + flex-wrap: wrap; + } + } + + @media (max-width: 480px) { + .csd-grid { + grid-template-columns: 1fr; + } + + .csd-field--name, + .csd-field--address { + grid-column: span 1; + } } ` ] @@ -217,20 +534,18 @@ export class CustomerSearchDialogComponent implements OnInit { private http = inject(HttpClient); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + private data = inject(MAT_DIALOG_DATA, { optional: true }); form!: FormGroup; loading = false; searched = false; results: CustomerSearchResult[] = []; - displayedColumns = [ - 'displayName', - 'account', - 'mobileNo', - 'officeName', - 'panNumber', - 'aadhaarNumber', - 'address', - 'select' + + readonly accountTypeOptions: Array<{ key: AccountTypeKey; label: string; icon: string }> = [ + { key: 'savings', label: 'Savings', icon: 'savings' }, + { key: 'current', label: 'Current', icon: 'account_balance_wallet' }, + { key: 'fixedDeposit', label: 'Fixed Deposit', icon: 'lock' }, + { key: 'recurringDeposit', label: 'Recurring Deposit', icon: 'autorenew' } ]; ngOnInit(): void { @@ -247,6 +562,48 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + /** The account screens need an account to open; a caller that only needs the customer opts out. */ + private get requireAccount(): boolean { + return this.data?.requireAccount !== false; + } + + canSelect(r: CustomerSearchResult): boolean { + return !this.requireAccount || !!r.accountNo; + } + + isTypeOn(key: AccountTypeKey): boolean { + return !!this.form?.get(key)?.value; + } + + toggleType(key: AccountTypeKey): void { + const control = this.form.get(key); + control?.setValue(!control.value); + } + + upperCase(name: string): void { + const control = this.form.get(name); + const value = String(control?.value ?? ''); + const upper = value.toUpperCase(); + if (value !== upper) { + control?.setValue(upper, { emitEvent: false }); + } + } + + /** Enter in a text box runs the search; on a button it keeps its own meaning. */ + onEnter(event: Event): void { + if ((event.target as HTMLElement)?.tagName !== 'INPUT') return; + event.preventDefault(); + if (!this.loading) this.onSearch(); + } + + initials(name: string | null): string { + const words = (name || '').trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return '?'; + const first = words[0].charAt(0); + const last = words.length > 1 ? words[words.length - 1].charAt(0) : ''; + return (first + last).toUpperCase(); + } + accountTypeLabel(type: string | null): string { switch (type) { case 'SAVINGS': @@ -297,6 +654,22 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + onClear(): void { + this.form.reset({ + name: '', + accountNumber: '', + aadhaarNumber: '', + panNumber: '', + address: '', + savings: false, + current: false, + fixedDeposit: false, + recurringDeposit: false + }); + this.results = []; + this.searched = false; + } + onSelect(result: CustomerSearchResult): void { this.dialogRef.close(result); } From 4e3b49a96ac31d58841d342485af1b4759becd96 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:41:39 +0530 Subject: [PATCH 21/21] feat(ui): remove Undo / Revert transaction actions Removes the Undo button and Undo menu item from savings, current, fixed deposit, recurring deposit and loan transactions, the account-transfer Undo, and the Revert Transaction button on the journal entry transaction detail, to prevent accidental reversal of posted entries. Co-Authored-By: Claude Sonnet 5.5 --- .../view-account-transfer.component.html | 10 ------- .../transactions-tab.component.html | 12 -------- ...nts-transaction-general-tab.component.html | 12 -------- .../transactions-tab.component.html | 6 ---- .../view-transaction.component.html | 15 +--------- .../view-transaction.component.html | 8 ----- .../transactions-tab.component.html | 4 --- .../view-transaction.component.html | 3 -- .../transactions-tab.component.html | 12 -------- ...ngs-transaction-general-tab.component.html | 10 ------- ...w-journal-entry-transaction.component.html | 18 ----------- ...iew-journal-entry-transaction.component.ts | 30 +------------------ 12 files changed, 2 insertions(+), 138 deletions(-) diff --git a/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html b/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html index 9d1b9ae34..cac9ad40d 100644 --- a/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html +++ b/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html @@ -7,16 +7,6 @@ -->
-
- @if (!viewAccountTransferData.reversed) { - - - - } -
-
diff --git a/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html b/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html index b0a948417..f39e90652 100644 --- a/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html @@ -135,18 +135,6 @@ {{ 'labels.text.View Transaction' | translate }} - @if (!transaction.reversed) { - - - - } - - }
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index 6e447a59c..bef30de6c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -130,12 +130,6 @@ {{ 'labels.text.View Transaction' | translate }} - @if (!isFirstTransaction(idx)) { - - } - - } -
+
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html index ec66cc45a..e488fdd63 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html @@ -15,14 +15,6 @@ } -
} diff --git a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html index 00bf57722..9a138bac5 100644 --- a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html +++ b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html @@ -177,10 +177,6 @@ } @if (allowUndoTransaction(transaction)) { - - - @if (!transaction.reversed) { - - - - } } - @if (allowUndo()) { - - }
diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html index 3f6ee9887..4fecc1971 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html @@ -24,24 +24,6 @@ }
-
- @if (dataSource?.data[0].manualEntry) { - - } -
diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index fa6a999a2..2b3f5c753 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -8,10 +8,8 @@ import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component'; -import { RevertTransactionComponent } from 'app/accounting/revert-transaction/revert-transaction.component'; -import { AccountingService } from 'app/accounting/accounting.service'; import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatPaginator } from '@angular/material/paginator'; import { @@ -65,9 +63,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; ] }) export class ViewJournalEntryTransactionComponent implements OnInit { - private accountingService = inject(AccountingService); private route = inject(ActivatedRoute); - private router = inject(Router); dialog = inject(MatDialog); private location = inject(Location); @@ -147,30 +143,6 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.dialog.open(ViewJournalEntryComponent, { data: { journalEntry } }); } - revertTransaction(transactionId?: string) { - const revertTransactionDialogRef = this.dialog.open(RevertTransactionComponent, { - data: { reverted: this.dataSource.data[0].reversed, transactionId } - }); - revertTransactionDialogRef.afterClosed().subscribe((response: any) => { - if (response.revert) { - this.accountingService - .revertTransaction(this.transactionId, response.comments) - .subscribe((reversedTransaction: any) => { - this.dataSource.data[0].reversed = true; - this.revertTransaction(reversedTransaction.transactionId); - }); - } else if (response.redirect) { - this.router.navigate( - [ - '../', - transactionId - ], - { relativeTo: this.route } - ); - } - }); - } - goBack(): void { this.location.back(); }