From 53bb97d6ed7eeb4bc892376672ec7960df162eaa Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 28 Oct 2026 12:43:51 +0530 Subject: [PATCH] feat(dds): DDS product creation in tabs with an Accounting tab - DDS Product Creation split into Details / Interest & Maturity / Premature Closure / Accounting tabs, like the savings product - Accounting tab picks Customer Deposits (liability), Interest Expense and Agent Commission Expense (expense) GLs; product view shows them - Approval review shows the product's GLs, old -> new on edits - Agent master no longer reads the removed dds-deposit-product setting Co-Authored-By: Claude Opus 5.5 --- .../dds-approval-review.component.html | 81 +++- .../dds-approval-review.component.scss | 110 ++++++ .../dds-approval-review.component.ts | 21 ++ .../create-agent/create-agent.component.ts | 31 +- .../create-dds-account-type.component.html | 353 ++++++++++++------ .../create-dds-account-type.component.scss | 305 ++++++++++++--- .../create-dds-account-type.component.ts | 168 ++++++++- .../view-dds-account-type.component.html | 29 ++ .../view-dds-account-type.component.ts | 5 + 9 files changed, 885 insertions(+), 218 deletions(-) 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 index fc3137e05..7a8ca9bbb 100644 --- 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 @@ -128,27 +128,68 @@ {{ group.changed }} changed } -
- @for (field of group.fields; track field.key) { -
- - {{ field.label }} - @if (field.changed) { - Changed - } - - @if (field.changed) { - - {{ field.current || '—' }} - arrow_forward - + @if (group.name === 'Accounting') { + +
+ @for (field of group.fields; track field.key) { +
+
+ {{ + glKind(field.key) + }} + + {{ field.label }} + @if (field.changed) { + Changed + } + +
+
+ @if (field.changed) { + + {{ glName(field.current) }} + @if (glCode(field.current)) { + {{ glCode(field.current) }} + } + + arrow_forward + } + + {{ glName(field.value) }} + @if (glCode(field.value)) { + {{ glCode(field.value) }} + } + +
+
+ } +
+ } @else { +
+ @for (field of group.fields; track field.key) { +
+ + {{ field.label }} + @if (field.changed) { + Changed + } - } @else { - - } -
- } -
+ @if (field.changed) { + + {{ field.current || '—' }} + arrow_forward + + + } @else { + + } +
+ } +
+ } } diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.scss b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.scss index f0ef10c8e..00712b2fe 100644 --- a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.scss +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.scss @@ -451,3 +451,113 @@ $shadow-sm: } } } + +// ── A DDS product's Accounting, as its own Accounting tab shows it ─────────────────────────────────── +.ddr-acct { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 20px 20px; + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid $border; + border-radius: 10px; + background: $bg-white; + + &--changed { + border-color: $warning-border; + background: $warning-bg; + } + + @media (width <= 720px) { + flex-direction: column; + align-items: flex-start; + } + } + + &__meta { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + &__kind { + flex: none; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + + &--liability { + background: #dbeafe; + color: #1e40af; + } + + &--expense { + background: #fef3c7; + color: #92400e; + } + } + + &__label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: $text-dark; + } + + &__value { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + } + + &__gl { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: $brand; + + &--old { + font-weight: 500; + color: $text-soft; + text-decoration: line-through; + } + + &--none { + color: #b91c1c; + } + } + + &__code { + padding: 1px 8px; + border: 1px solid $border; + border-radius: 6px; + background: $bg-soft; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: $text-mid; + } + + &__arrow { + width: 18px; + height: 18px; + font-size: 18px; + color: $warning; + } +} 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 index eb80eecf5..a5e1dce09 100644 --- 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 @@ -123,6 +123,26 @@ export class DdsApprovalReviewComponent implements OnInit { return this.fields.find((field: any) => field.key === key)?.value || ''; } + /** The kind of ledger account a product's Accounting row names - customers' deposits are owed, the rest are costs. */ + glKind(key: string): string { + return key === 'depositLiabilityGlId' ? 'Liability' : 'Expense'; + } + + /** "DDS CUSTOMER DEPOSITS (200601)" → "DDS CUSTOMER DEPOSITS"; a missing account reads "Not set". */ + glName(value: string | null | undefined): string { + if (!value) { + return 'Not set'; + } + const match = /^(.*) (([^()]+))$/.exec(value); + return match ? match[1] : value; + } + + /** "DDS CUSTOMER DEPOSITS (200601)" → "200601". */ + glCode(value: string | null | undefined): string { + const match = value ? /^(.*) (([^()]+))$/.exec(value) : null; + return match ? match[2] : ''; + } + get changedCount(): number { return this.fields.filter((field: any) => field.changed).length; } @@ -131,6 +151,7 @@ export class DdsApprovalReviewComponent implements OnInit { Product: 'inventory_2', Interest: 'trending_up', 'Maturity & Premature Closure': 'event_busy', + Accounting: 'menu_book', 'Customer & Scheme': 'groups', 'Personal & Contact': 'contact_page', 'Minor / Guardian': 'escalator_warning', 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 14b97e687..42f8da5e5 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 @@ -25,7 +25,6 @@ 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 { SavingsService } from 'app/savings/savings.service'; import { SettingsService } from 'app/settings/settings.service'; @@ -90,7 +89,6 @@ export class CreateAgentComponent implements OnInit { private clientsService = inject(ClientsService); private savingsService = inject(SavingsService); private accountingService = inject(AccountingService); - private systemService = inject(SystemService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private alertService = inject(AlertService); @@ -286,17 +284,6 @@ export class CreateAgentComponent implements OnInit { ); } - /** - * 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. @@ -420,19 +407,6 @@ export class CreateAgentComponent implements OnInit { 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 || []; @@ -751,11 +725,8 @@ export class CreateAgentComponent implements OnInit { // 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. + // A DDS customer's scheme account would pass this too; the server refuses one on save. 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 = [ 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 0f34fac75..bb58777ab 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 @@ -7,133 +7,262 @@ -->
-
-
-
-
-

DDS Product Creation

-

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

-
+
+
+
+

DDS Product Creation

+

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

+
-
-
- - Product Group - - @for (group of accountTypeGroups; track group) { - {{ group }} - } - - - - - Product Code - - - - - Product Description - - - - - Interest Rate (%) - - Interest is worked out daily on the balance (rate ÷ 365) - @if (accountTypeForm.get('interestRate')?.hasError('required')) { - Interest rate is required + + + + + + + + Details + @if (tabHasError(detailsControls)) { + } - + +
+
+ + Product Group + + @for (group of accountTypeGroups; track group) { + {{ group }} + } + + - - Auto Interest Post - - Yes - No - - + + Product Code + + - - Auto Interest Post When - - @for (option of autoInterestPostWhenOptions; track option) { - {{ option }} - } - - - Interest posts on the last day of each month, quarter, half-year or year - + + Product Description + + +
+
+ - - - Maturity Auto Close - - Yes - No - - Closes the account on its maturity date, if it names a savings account to be paid into - -
+ + + + + Interest & Maturity + @if (tabHasError(interestControls)) { + + } + +
+
+ + 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 + + Yes + No + + + + + Auto Interest Post When + + @for (option of autoInterestPostWhenOptions; track option) { + {{ option }} + } + + + Interest posts on the last day of each month, quarter, half-year or year + + + + + 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. -

+ + + + Premature Closure + @if (tabHasError(prematureControls)) { + + } + +
+

+ 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. - +
+ + 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. - + + 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. - -
+ + Agent: commission reduced by (%) + + Charged whoever bears the interest — the round was not kept going. + +
+
+
-
- - + + + @if (selectedTabIndex < lastTabIndex) { + -
-
-
+ } + +
+
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 index a6a629710..fe521eece 100644 --- 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 @@ -6,57 +6,104 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.dat-root { - padding: 20px 24px 40px; -} +// Laid out like the savings product screen - a header, then the form in tabs - in the DDS screens' +// own teal. +$teal: #0f766e; +$teal-dark: #134e4a; +$teal-soft: #e6f4ee; +$border: #e2e8f0; +$text: #0f172a; +$text-mid: #475569; +$text-soft: #94a3b8; -.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; +.dat-root { + max-width: 960px; margin: 0 auto; + padding: 24px 16px 40px; } .dat-head { display: flex; align-items: center; gap: 14px; - padding: 20px 24px; - border-bottom: 1px solid rgb(0 0 0 / 7%); + margin-bottom: 18px; h2 { margin: 0; font-size: 1.2rem; font-weight: 600; + color: $text; } p { margin: 2px 0 0; font-size: 0.83rem; - color: rgb(0 0 0 / 55%); + color: $text-mid; + } + + &__ic { + display: grid; + place-items: center; + flex: none; + width: 46px; + height: 46px; + border-radius: 12px; + font-size: 1.05rem; + color: #fff; + background: linear-gradient(135deg, $teal, $teal-dark); } } -.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; +// ── Tabs ───────────────────────────────────────────────────────────────────────────────────────────── +.dat-tabs { + background: #fff; + border-radius: 10px; + 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: 130px; + padding: 0 16px; + } + + .mat-mdc-tab.mdc-tab--active .mdc-tab__text-label { + color: $teal; + font-weight: 600; + } + + .mdc-tab-indicator__content--underline { + border-color: $teal !important; + } + } +} + +.dat-tab-icon { + margin-right: 8px; + font-size: 13px; +} + +// A tab with something still to fill in. +.dat-tab-dot { + display: inline-block; + width: 7px; + height: 7px; + margin-left: 6px; + border-radius: 50%; + background: #dc2626; +} + +.dat-tab-content { + padding: 24px 20px; } .dat-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 8px 20px; - padding: 26px 24px 8px; } .dat-field { @@ -67,42 +114,196 @@ 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; /* stylelint-disable-line unit-allowed-list */ +.dat-note { + margin: 0 0 18px; + max-width: 72ch; /* stylelint-disable-line unit-allowed-list */ font-size: 0.8rem; line-height: 1.5; - color: rgb(0 0 0 / 60%); + color: $text-mid; } -/* 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-gl-error { + margin: 0 0 16px; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + font-size: 0.82rem; + color: #b91c1c; + + i { + margin-right: 6px; + } } +// ── Accounting tab, as the savings product's ────────────────────────────────────────────────────────── +.spa-section { + overflow: hidden; + border: 1px solid $border; + border-radius: 10px; + background: #fff; + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-bottom: 1px solid #e8eaed; + background: #f8f9fa; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #202124; + } + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: $teal-soft; + color: $teal; + font-size: 13px; + } + + &__body { + padding: 16px 20px 20px; + } +} + +.spa-group { + & + & { + margin-top: 20px; + } + + h4 { + margin: 0 0 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: $text-soft; + } +} + +.spa-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.spa-row { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + + @media (width <= 900px) { + flex-direction: column; + } + + &__meta { + display: flex; + align-items: center; + gap: 10px; + flex: 1 1 auto; + min-width: 0; + } + + &__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__label { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + &__hint { + font-size: 12px; + line-height: 1.4; + color: $text-mid; + } + + &__input { + display: block; + flex: 0 0 22rem; + min-width: 18rem; + max-width: 22rem; + + @media (width <= 900px) { + width: 100%; + min-width: 0; + max-width: 100%; + } + + ::ng-deep { + .layout-column { + width: 100%; + margin-right: 0 !important; + } + + mat-form-field { + width: 100%; + } + } + } +} + +.spa-req { + color: #dc2626; +} + +.spa-pill { + flex: none; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + + &--blue { + background: #dbeafe; + color: #1e40af; + } + + &--amber { + background: #fef3c7; + color: #92400e; + } +} + +// ── Footer ─────────────────────────────────────────────────────────────────────────────────────────── .dat-actions { display: flex; - justify-content: flex-end; - gap: 12px; - padding: 16px 24px; - border-top: 1px solid rgb(0 0 0 / 7%); + align-items: center; + gap: 10px; + margin-top: 16px; + padding: 14px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__spacer { + flex: 1; + } + + i { + margin: 0 4px; + font-size: 12px; + } } @media (width <= 720px) { 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 c6e5d4003..17cb0a873 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 @@ -11,19 +11,40 @@ 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 { MatTabsModule } from '@angular/material/tabs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component'; +import { GLAccount } from 'app/shared/models/general.model'; /** 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 { AccountingService } from 'app/accounting/accounting.service'; import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; +/** acc_gl_account classification: 2 liability, 5 expense. */ +const GL_LIABILITY = 2; +const GL_EXPENSE = 5; + +/** One ledger account the product names, as a row on the Accounting tab. */ +interface AccountingRow { + controlName: string; + label: string; + inputLabel: string; + hint: string; + pill: 'blue' | 'amber'; + pillLabel: string; + required: boolean; + options: GLAccount[]; +} + /** * 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. + * description, and whether interest posts automatically and how often - laid out in tabs the way + * the savings product screen is, with an Accounting tab for the ledger accounts the product posts + * through. * * The same form handles create and edit; an `accountTypeId` in the route switches it to edit. */ @@ -33,7 +54,9 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro styleUrls: ['./create-dds-account-type.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FaIconComponent + FaIconComponent, + MatTabsModule, + GlAccountSelectorComponent ] }) export class CreateDdsAccountTypeComponent implements OnInit { @@ -44,6 +67,38 @@ export class CreateDdsAccountTypeComponent implements OnInit { private alertService = inject(AlertService); private organizationService = inject(OrganizationService); private settingsService = inject(SettingsService); + private accountingService = inject(AccountingService); + + /** The tab on show; Previous and Next move it. */ + selectedTabIndex = 0; + readonly lastTabIndex = 3; + + /** Which controls sit on which tab - a tab whose controls are invalid shows a dot on its label. */ + readonly detailsControls = [ + 'accountTypeGroup', + 'accountTypeCode', + 'accountTypeDescription' + ]; + readonly interestControls = [ + 'interestRate', + 'autoInterestPost', + 'autoInterestPostWhen', + 'maturityAutoClose' + ]; + readonly prematureControls = [ + 'prematureLockinMonths', + 'prematureInterestReduction', + 'prematureAgentCommissionPenalty' + ]; + readonly accountingControls = [ + 'depositLiabilityGlId', + 'interestExpenseGlId', + 'commissionExpenseGlId' + ]; + + /** The Accounting tab's rows, grouped by kind as the savings product's are. */ + accountingSections: { key: string; title: string; rows: AccountingRow[] }[] = []; + glLoadFailed = false; accountTypeGroups = ['DDS']; @@ -124,6 +179,23 @@ export class CreateDdsAccountTypeComponent implements OnInit { Validators.min(0), Validators.max(100) ] + ], + /* + * The product's ledger accounts (Accounting tab) - the only accounts DDS posts through for this + * product; nothing is taken from Global Configurations. All three are required, as a savings + * product's control and interest accounts are. + */ + depositLiabilityGlId: [ + null, + Validators.required + ], + interestExpenseGlId: [ + null, + Validators.required + ], + commissionExpenseGlId: [ + null, + Validators.required ] }); @@ -132,6 +204,7 @@ export class CreateDdsAccountTypeComponent implements OnInit { } ngOnInit(): void { + this.loadLedgerAccounts(); const routeId = this.route.snapshot.paramMap.get('accountTypeId'); if (!routeId) { return; @@ -150,17 +223,104 @@ export class CreateDdsAccountTypeComponent implements OnInit { // that charges nothing - and must not be replaced by the default. prematureLockinMonths: accountType.prematureLockinMonths ?? 12, prematureInterestReduction: accountType.prematureInterestReduction ?? 1, - prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1 + prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1, + depositLiabilityGlId: accountType.depositLiabilityGlId ?? null, + interestExpenseGlId: accountType.interestExpenseGlId ?? null, + commissionExpenseGlId: accountType.commissionExpenseGlId ?? null }); }); } + /** True once a control on the tab is invalid and the user has been there or tried to submit. */ + tabHasError(controls: string[]): boolean { + return controls.some((name) => { + const control = this.accountTypeForm.get(name); + return !!control && control.invalid && (control.touched || control.dirty); + }); + } + + previousTab(): void { + this.selectedTabIndex = Math.max(0, this.selectedTabIndex - 1); + } + + nextTab(): void { + this.selectedTabIndex = Math.min(this.lastTabIndex, this.selectedTabIndex + 1); + } + + /** + * Detail accounts that can take a posting, split by kind so each row offers only the kind its job + * needs - the backend refuses any other kind, and this keeps the wrong one from being picked at all. + */ + private loadLedgerAccounts(): void { + this.accountingService.getChartOfAccounts().subscribe({ + next: (accounts: any) => { + const rows: GLAccount[] = (Array.isArray(accounts) ? accounts : accounts?.pageItems || []).filter( + (a: any) => a.usage?.id === 1 && !a.disabled + ); + const ofType = (...types: number[]) => rows.filter((a: any) => types.includes(a.type?.id)); + this.buildAccountingSections(ofType(GL_LIABILITY), ofType(GL_EXPENSE)); + }, + error: () => { + this.glLoadFailed = true; + this.buildAccountingSections([], []); + } + }); + } + + private buildAccountingSections(liabilities: GLAccount[], expenses: GLAccount[]): void { + this.accountingSections = [ + { + key: 'liabilities', + title: 'Liabilities', + rows: [ + { + controlName: 'depositLiabilityGlId', + label: 'DDS Customer Deposits', + inputLabel: 'Customer deposits account', + hint: 'What the bank owes the customers on this product - credited on collection, debited on payout.', + pill: 'blue', + pillLabel: 'Liability', + required: true, + options: liabilities + } + ] + }, + { + key: 'expenses', + title: 'Expenses', + rows: [ + { + controlName: 'interestExpenseGlId', + label: 'Interest Expense', + inputLabel: 'Interest expense account', + hint: 'What interest paid to the customers on this product costs the bank.', + pill: 'amber', + pillLabel: 'Expense', + required: true, + options: expenses + }, + { + controlName: 'commissionExpenseGlId', + label: 'Agent Commission Expense', + inputLabel: 'Commission expense account', + hint: "Agents' commission on collecting for this product. An agent collecting for several products is shared out by what was collected for each.", + pill: 'amber', + pillLabel: 'Expense', + required: true, + options: expenses + } + ] + } + ]; + } + cancel(): void { this.backToList(); } submit(): void { if (this.accountTypeForm.invalid) { + this.accountTypeForm.markAllAsTouched(); return; } const raw = this.accountTypeForm.getRawValue(); diff --git a/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.html b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.html index fd228ac69..3ce3dae2d 100644 --- a/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.html +++ b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.html @@ -160,5 +160,34 @@ + + +
+
+
+

Accounting

+
+
+
+ DDS Customer Deposits (Liability) + {{ + glLabel(accountType.depositLiabilityGlCode, accountType.depositLiabilityGlName) + }} + Credited on collection, debited on payout +
+
+ Interest Expense + {{ glLabel(accountType.interestExpenseGlCode, accountType.interestExpenseGlName) }} + Debited when interest is posted to customers +
+
+ Agent Commission Expense + {{ + glLabel(accountType.commissionExpenseGlCode, accountType.commissionExpenseGlName) + }} + Debited with this product's share of agents' commission +
+
+
} diff --git a/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts index dc1f10903..c34c39451 100644 --- a/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts +++ b/src/app/products/dds-account-types/view-dds-account-type/view-dds-account-type.component.ts @@ -77,6 +77,11 @@ export class ViewDdsAccountTypeComponent implements OnInit { return `${Number(value)}%`; } + /** A ledger account as "NAME (code)", or a plain "Not set" when the product names none. */ + glLabel(code: string | null | undefined, name: string | null | undefined): string { + return code ? `${name} (${code})` : 'Not set'; + } + back(): void { this.router.navigate(['/products/dds-account-types']); }