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:
@@ -128,6 +128,46 @@
|
||||
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
|
||||
}
|
||||
</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">
|
||||
@for (field of group.fields; track field.key) {
|
||||
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
|
||||
@@ -149,6 +189,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
+110
@@ -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 || '';
|
||||
}
|
||||
|
||||
/** 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',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+138
-9
@@ -7,7 +7,6 @@
|
||||
-->
|
||||
|
||||
<div class="dat-root">
|
||||
<div class="dat-card">
|
||||
<div class="dat-head">
|
||||
<div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div>
|
||||
@@ -16,7 +15,27 @@
|
||||
</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">
|
||||
<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">
|
||||
<mat-form-field appearance="outline" class="dat-field">
|
||||
<mat-label>Product Group</mat-label>
|
||||
@@ -29,14 +48,34 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="dat-field">
|
||||
<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 appearance="outline" class="dat-field dat-field--wide">
|
||||
<mat-label>Product Description</mat-label>
|
||||
<input matInput required maxlength="200" formControlName="accountTypeDescription" />
|
||||
</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 & 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-label>Interest Rate (%)</mat-label>
|
||||
<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]="false">No</mat-option>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ── Premature Closure ───────────────────────────────────────────────────────────────── -->
|
||||
<!--
|
||||
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,
|
||||
and a figure typed at the counter is a figure that can be typed differently next time.
|
||||
-->
|
||||
<h3 class="dat-section">Premature Closure</h3>
|
||||
<p class="dat-section-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.
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-door-open dat-tab-icon"></i>
|
||||
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>
|
||||
|
||||
<div class="dat-grid">
|
||||
@@ -126,9 +178,87 @@
|
||||
<mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint>
|
||||
</mat-form-field>
|
||||
</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">
|
||||
<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()">
|
||||
<fa-icon icon="check" style="margin-right: 6px"></fa-icon>
|
||||
{{ isEdit ? 'Update' : 'Submit' }}
|
||||
@@ -136,4 +266,3 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+249
-48
@@ -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;
|
||||
}
|
||||
|
||||
.dat-head__ic {
|
||||
&__ic {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
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;
|
||||
background: linear-gradient(135deg, $teal, $teal-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
|
||||
+164
-4
@@ -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();
|
||||
|
||||
+29
@@ -160,5 +160,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+5
@@ -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']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user