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 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 12:43:51 +05:30
co-authored by Claude Opus 5.5
parent 293b671966
commit 53bb97d6ed
9 changed files with 885 additions and 218 deletions
@@ -128,6 +128,46 @@
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span> <span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
} }
</header> </header>
@if (group.name === 'Accounting') {
<!--
The ledger accounts the product posts through, laid out as the product's own Accounting
tab lays them out: the kind of account, what it is for, and the account by name and code.
-->
<div class="ddr-acct">
@for (field of group.fields; track field.key) {
<div class="ddr-acct__row" [class.ddr-acct__row--changed]="field.changed">
<div class="ddr-acct__meta">
<span class="ddr-acct__kind" [ngClass]="'ddr-acct__kind--' + glKind(field.key).toLowerCase()">{{
glKind(field.key)
}}</span>
<span class="ddr-acct__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
</div>
<div class="ddr-acct__value">
@if (field.changed) {
<span class="ddr-acct__gl ddr-acct__gl--old">
{{ glName(field.current) }}
@if (glCode(field.current)) {
<span class="ddr-acct__code">{{ glCode(field.current) }}</span>
}
</span>
<mat-icon class="ddr-acct__arrow">arrow_forward</mat-icon>
}
<span class="ddr-acct__gl" [class.ddr-acct__gl--none]="!field.value">
{{ glName(field.value) }}
@if (glCode(field.value)) {
<span class="ddr-acct__code">{{ glCode(field.value) }}</span>
}
</span>
</div>
</div>
}
</div>
} @else {
<div class="ddr-grid"> <div class="ddr-grid">
@for (field of group.fields; track field.key) { @for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed"> <div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
@@ -149,6 +189,7 @@
</div> </div>
} }
</div> </div>
}
</section> </section>
} }
@@ -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;
}
}
@@ -123,6 +123,26 @@ export class DdsApprovalReviewComponent implements OnInit {
return this.fields.find((field: any) => field.key === key)?.value || ''; 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 { get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length; return this.fields.filter((field: any) => field.changed).length;
} }
@@ -131,6 +151,7 @@ export class DdsApprovalReviewComponent implements OnInit {
Product: 'inventory_2', Product: 'inventory_2',
Interest: 'trending_up', Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy', 'Maturity & Premature Closure': 'event_busy',
Accounting: 'menu_book',
'Customer & Scheme': 'groups', 'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page', 'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning', 'Minor / Guardian': 'escalator_warning',
@@ -25,7 +25,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */ /** Custom Services */
import { OrganizationService } from 'app/organization/organization.service'; import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service'; import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service'; import { SavingsService } from 'app/savings/savings.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
@@ -90,7 +89,6 @@ export class CreateAgentComponent implements OnInit {
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService); private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private alertService = inject(AlertService); 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 * 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. * 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 = []) 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({ this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => { next: (accounts: any) => {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || []; 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 // 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 - // 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. // 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 // A DDS customer's scheme account would pass this too; the server refuses one on save.
// 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 && account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved) (account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
); );
this.savingsAccountTypes = [ this.savingsAccountTypes = [
@@ -7,7 +7,6 @@
--> -->
<div class="dat-root"> <div class="dat-root">
<div class="dat-card">
<div class="dat-head"> <div class="dat-head">
<div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div> <div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div> <div>
@@ -16,7 +15,27 @@
</div> </div>
</div> </div>
<!--
One form, split into tabs the way the savings product screen is. Every tab stays in the DOM
(preserveContent), so a field filled on one tab is still there when the user comes back to it.
-->
<form [formGroup]="accountTypeForm"> <form [formGroup]="accountTypeForm">
<mat-tab-group
class="dat-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- ── Details ─────────────────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-info-circle dat-tab-icon"></i>
Details
@if (tabHasError(detailsControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="dat-grid"> <div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field"> <mat-form-field appearance="outline" class="dat-field">
<mat-label>Product Group</mat-label> <mat-label>Product Group</mat-label>
@@ -29,14 +48,34 @@
<mat-form-field appearance="outline" class="dat-field"> <mat-form-field appearance="outline" class="dat-field">
<mat-label>Product Code</mat-label> <mat-label>Product Code</mat-label>
<input matInput required maxlength="20" formControlName="accountTypeCode" style="text-transform: uppercase" /> <input
matInput
required
maxlength="20"
formControlName="accountTypeCode"
style="text-transform: uppercase"
/>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="dat-field dat-field--wide"> <mat-form-field appearance="outline" class="dat-field dat-field--wide">
<mat-label>Product Description</mat-label> <mat-label>Product Description</mat-label>
<input matInput required maxlength="200" formControlName="accountTypeDescription" /> <input matInput required maxlength="200" formControlName="accountTypeDescription" />
</mat-form-field> </mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── Interest & Maturity ─────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent dat-tab-icon"></i>
Interest &amp; Maturity
@if (tabHasError(interestControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field"> <mat-form-field appearance="outline" class="dat-field">
<mat-label>Interest Rate (%)</mat-label> <mat-label>Interest Rate (%)</mat-label>
<input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" /> <input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" />
@@ -76,19 +115,32 @@
<mat-option [value]="true">Yes</mat-option> <mat-option [value]="true">Yes</mat-option>
<mat-option [value]="false">No</mat-option> <mat-option [value]="false">No</mat-option>
</mat-select> </mat-select>
<mat-hint>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint> <mat-hint
>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint
>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
</mat-tab>
<!-- ── Premature Closure ───────────────────────────────────────────────────────────────── -->
<!-- <!--
The terms for closing an account on this scheme early. Set once here rather than asked of the The terms for closing an account on this scheme early. Set once here rather than asked of the
clerk at closing time: they are the bank's terms and the same for every account on the scheme, clerk at closing time: they are the bank's terms and the same for every account on the scheme,
and a figure typed at the counter is a figure that can be typed differently next time. and a figure typed at the counter is a figure that can be typed differently next time.
--> -->
<h3 class="dat-section">Premature Closure</h3> <mat-tab>
<p class="dat-section-note"> <ng-template mat-tab-label>
Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether <i class="fas fa-door-open dat-tab-icon"></i>
the customer or the agent bears the cost; these set what that costs. Premature Closure
@if (tabHasError(prematureControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<p class="dat-note">
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.
</p> </p>
<div class="dat-grid"> <div class="dat-grid">
@@ -126,9 +178,87 @@
<mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint> <mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
</mat-tab>
<!-- ── Accounting ──────────────────────────────────────────────────────────────────────── -->
<!--
The product's own ledger accounts, laid out the way the savings product's Accounting tab is:
grouped by kind, each row with the GL search picker. Only accounts of the right kind are offered.
-->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-book dat-tab-icon"></i>
Accounting
@if (tabHasError(accountingControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="spa-section">
<div class="spa-section__header">
<span class="spa-section__icon"><i class="fas fa-book"></i></span>
<h3>Accounting</h3>
</div>
<div class="spa-section__body">
@if (glLoadFailed) {
<div class="dat-gl-error">
<i class="fas fa-exclamation-triangle"></i>
The chart of accounts could not be loaded, so no ledger account can be picked. Reload the page to try
again.
</div>
}
@for (section of accountingSections; track section.key) {
<div class="spa-group">
<h4>{{ section.title }}</h4>
<div class="spa-list">
@for (row of section.rows; track row.controlName) {
<div class="spa-row">
<div class="spa-row__meta">
<span class="spa-pill" [ngClass]="'spa-pill--' + row.pill">{{ row.pillLabel }}</span>
<div class="spa-row__text">
<span class="spa-row__label">
{{ row.label }}
@if (row.required) {
<span class="spa-req">*</span>
}
</span>
<span class="spa-row__hint">{{ row.hint }}</span>
</div>
</div>
<mifosx-gl-account-selector
class="spa-row__input"
[inputFormControl]="accountTypeForm.get(row.controlName)"
[glAccountList]="row.options"
[required]="row.required"
[inputLabel]="row.inputLabel"
></mifosx-gl-account-selector>
</div>
}
</div>
</div>
}
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
<div class="dat-actions"> <div class="dat-actions">
<button mat-raised-button type="button" (click)="cancel()">Cancel</button> <button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<span class="dat-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="selectedTabIndex === 0" (click)="previousTab()">
<i class="fas fa-arrow-left"></i>
Previous
</button>
@if (selectedTabIndex < lastTabIndex) {
<button mat-stroked-button color="primary" type="button" (click)="nextTab()">
Next
<i class="fas fa-arrow-right"></i>
</button>
}
<button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()"> <button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()">
<fa-icon icon="check" style="margin-right: 6px"></fa-icon> <fa-icon icon="check" style="margin-right: 6px"></fa-icon>
{{ isEdit ? 'Update' : 'Submit' }} {{ isEdit ? 'Update' : 'Submit' }}
@@ -136,4 +266,3 @@
</div> </div>
</form> </form>
</div> </div>
</div>
@@ -6,57 +6,104 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
.dat-root { // Laid out like the savings product screen - a header, then the form in tabs - in the DDS screens'
padding: 20px 24px 40px; // own teal.
} $teal: #0f766e;
$teal-dark: #134e4a;
$teal-soft: #e6f4ee;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
.dat-card { .dat-root {
background: #fff; max-width: 960px;
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; margin: 0 auto;
padding: 24px 16px 40px;
} }
.dat-head { .dat-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
padding: 20px 24px; margin-bottom: 18px;
border-bottom: 1px solid rgb(0 0 0 / 7%);
h2 { h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
color: $text;
} }
p { p {
margin: 2px 0 0; margin: 2px 0 0;
font-size: 0.83rem; font-size: 0.83rem;
color: rgb(0 0 0 / 55%); color: $text-mid;
}
} }
.dat-head__ic { &__ic {
display: grid;
place-items: center;
flex: none;
width: 46px; width: 46px;
height: 46px; height: 46px;
border-radius: 12px; border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.05rem; font-size: 1.05rem;
color: #fff; color: #fff;
background: linear-gradient(135deg, #0f766e, #134e4a); background: linear-gradient(135deg, $teal, $teal-dark);
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 { .dat-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px 20px; gap: 8px 20px;
padding: 26px 24px 8px;
} }
.dat-field { .dat-field {
@@ -67,42 +114,196 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* .dat-note {
* Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a margin: 0 0 18px;
* second card, because it is the same record being filled in - the break is to stop three money max-width: 72ch; /* stylelint-disable-line unit-allowed-list */
* 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 */
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.5; 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-gl-error {
.dat-section-note + .dat-grid { margin: 0 0 16px;
padding-top: 14px; 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 { .dat-actions {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
gap: 12px; gap: 10px;
padding: 16px 24px; margin-top: 16px;
border-top: 1px solid rgb(0 0 0 / 7%); 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) { @media (width <= 720px) {
@@ -11,19 +11,40 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabsModule } from '@angular/material/tabs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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 */ /** Custom Services */
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service'; import { AlertService } from 'app/core/alert/alert.service';
import { OrganizationService } from 'app/organization/organization.service'; import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.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'; 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, * 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 * description, and whether interest posts automatically and how often - laid out in tabs the way
* the deposit-product engine, so there are no terms, charges or accounting tabs behind this. * 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. * 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'], styleUrls: ['./create-dds-account-type.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent FaIconComponent,
MatTabsModule,
GlAccountSelectorComponent
] ]
}) })
export class CreateDdsAccountTypeComponent implements OnInit { export class CreateDdsAccountTypeComponent implements OnInit {
@@ -44,6 +67,38 @@ export class CreateDdsAccountTypeComponent implements OnInit {
private alertService = inject(AlertService); private alertService = inject(AlertService);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService); 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']; accountTypeGroups = ['DDS'];
@@ -124,6 +179,23 @@ export class CreateDdsAccountTypeComponent implements OnInit {
Validators.min(0), Validators.min(0),
Validators.max(100) 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 { ngOnInit(): void {
this.loadLedgerAccounts();
const routeId = this.route.snapshot.paramMap.get('accountTypeId'); const routeId = this.route.snapshot.paramMap.get('accountTypeId');
if (!routeId) { if (!routeId) {
return; return;
@@ -150,17 +223,104 @@ export class CreateDdsAccountTypeComponent implements OnInit {
// that charges nothing - and must not be replaced by the default. // that charges nothing - and must not be replaced by the default.
prematureLockinMonths: accountType.prematureLockinMonths ?? 12, prematureLockinMonths: accountType.prematureLockinMonths ?? 12,
prematureInterestReduction: accountType.prematureInterestReduction ?? 1, 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 { cancel(): void {
this.backToList(); this.backToList();
} }
submit(): void { submit(): void {
if (this.accountTypeForm.invalid) { if (this.accountTypeForm.invalid) {
this.accountTypeForm.markAllAsTouched();
return; return;
} }
const raw = this.accountTypeForm.getRawValue(); const raw = this.accountTypeForm.getRawValue();
@@ -160,5 +160,34 @@
</div> </div>
</div> </div>
</section> </section>
<!-- ACCOUNTING: the only ledger accounts DDS posts through for this product -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="book"></fa-icon></div>
<h3>Accounting</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">DDS Customer Deposits (Liability)</span>
<span class="vsp-v">{{
glLabel(accountType.depositLiabilityGlCode, accountType.depositLiabilityGlName)
}}</span>
<span class="vdt-note">Credited on collection, debited on payout</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Interest Expense</span>
<span class="vsp-v">{{ glLabel(accountType.interestExpenseGlCode, accountType.interestExpenseGlName) }}</span>
<span class="vdt-note">Debited when interest is posted to customers</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Commission Expense</span>
<span class="vsp-v">{{
glLabel(accountType.commissionExpenseGlCode, accountType.commissionExpenseGlName)
}}</span>
<span class="vdt-note">Debited with this product's share of agents' commission</span>
</div>
</div>
</section>
} }
</div> </div>
@@ -77,6 +77,11 @@ export class ViewDdsAccountTypeComponent implements OnInit {
return `${Number(value)}%`; 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 { back(): void {
this.router.navigate(['/products/dds-account-types']); this.router.navigate(['/products/dds-account-types']);
} }