DDS: agent collection entry screen, per-agent GL account, maturity auto close
The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what settles is what the entries say, to the rupee. Agent Master now asks which ledger account the agent's collections pass through, one agent to an account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the agent's own savings list for the same reason the server refuses it: debits are blocked on those accounts, so commission paid into one would go in and never come out. The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest Post question directly above it, and Batch Processing offers the run that applies it. Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that holds a word, and an agent's birth date cannot be picked later than eighteen years ago. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
57a61b3057
commit
e2eff9914f
Generated
+74
-5363
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ import { Routes, RouterModule } from '@angular/router';
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { AccountsListComponent } from './accounts-list/accounts-list.component';
|
||||
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
|
||||
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
|
||||
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
|
||||
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
|
||||
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
|
||||
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
|
||||
@@ -48,8 +50,26 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'dds',
|
||||
component: PartyMasterComponent,
|
||||
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }
|
||||
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
|
||||
children: [
|
||||
{ path: '', component: PartyMasterComponent },
|
||||
{
|
||||
path: 'create',
|
||||
component: CreatePartyComponent,
|
||||
data: { title: 'Create Party', breadcrumb: 'Create' }
|
||||
},
|
||||
{
|
||||
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
|
||||
path: 'dueforclosure',
|
||||
component: DueForClosureComponent,
|
||||
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
|
||||
},
|
||||
{
|
||||
path: ':partyId/edit',
|
||||
component: CreatePartyComponent,
|
||||
data: { title: 'Edit Party', breadcrumb: 'Edit' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'dds-collection',
|
||||
|
||||
@@ -1048,7 +1048,7 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
|
||||
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']" (click)="depositsMenu.closed.emit()">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS
|
||||
</button>
|
||||
</div>
|
||||
@@ -1057,7 +1057,7 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']" (click)="depositsMenu.closed.emit()">
|
||||
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
@@ -1072,7 +1072,7 @@
|
||||
</div>
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Reports</div>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']" (click)="depositsMenu.closed.emit()">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
|
||||
@@ -39,21 +39,90 @@
|
||||
|
||||
<div class="dte-info-panel">
|
||||
<div class="dte-info-panel__title">Party Information</div>
|
||||
<p class="dte-info-panel__hint">Populated once a party is selected in a collection row below.</p>
|
||||
@if (partyInfo) {
|
||||
<dl>
|
||||
<dt>Client Id</dt>
|
||||
<dd>{{ partyInfo.clientId }}</dd>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ partyInfo.partyName }}</dd>
|
||||
<dt>Address</dt>
|
||||
<dd>{{ partyInfo.address1 || '—' }}</dd>
|
||||
<dt>City</dt>
|
||||
<dd>{{ partyInfo.city || '—' }}</dd>
|
||||
<dt>Op. Date</dt>
|
||||
<dd>{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}</dd>
|
||||
<dt>Maturity</dt>
|
||||
<dd>{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}</dd>
|
||||
<dt>Daily Depo</dt>
|
||||
<dd>{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}</dd>
|
||||
<dt>Ledger</dt>
|
||||
<dd>{{ partyInfo.ledgerNo || '—' }}</dd>
|
||||
<dt>Aadhar No</dt>
|
||||
<dd>{{ partyInfo.aadharNo || '—' }}</dd>
|
||||
<dt>Curr Bal</dt>
|
||||
<dd>{{ partyInfo.currentBalance | number: '1.2-2' }}</dd>
|
||||
<!--
|
||||
What should have been collected by today against what has been. Beside the balance
|
||||
rather than instead of it: the balance is what the customer holds, this is whether
|
||||
they have kept to the scheme.
|
||||
-->
|
||||
<dt>Due So Far</dt>
|
||||
<dd>{{ partyInfo.expectedToDate | number: '1.2-2' }}</dd>
|
||||
<dt>Collected</dt>
|
||||
<dd>{{ partyInfo.collectedToDate | number: '1.2-2' }}</dd>
|
||||
@if (schedule(partyInfo); as sched) {
|
||||
<dt [class]="'dte-sched dte-sched--' + sched.state">Schedule</dt>
|
||||
<dd [class]="'dte-sched dte-sched--' + sched.state">{{ sched.text }}</dd>
|
||||
}
|
||||
<dt class="dte-after">Bal. After This Tr</dt>
|
||||
<dd class="dte-after">{{ partyBalanceAfter | number: '1.2-2' }}</dd>
|
||||
</dl>
|
||||
} @else {
|
||||
<p class="dte-info-panel__hint">
|
||||
Pick a party in a collection row below — this panel shows whose account it is and what
|
||||
the balance becomes.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dte-controls">
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
|
||||
<mat-label>Agent</mat-label>
|
||||
<input matInput required formControlName="agentId" />
|
||||
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
|
||||
@for (agent of selectableAgents; track agent.id) {
|
||||
<mat-option [value]="agent.id">
|
||||
{{ agent.agentCode }} · {{ agent.agentNameId }}
|
||||
@if (!agent.active) {
|
||||
<span class="dte-agent-off">— not collecting</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (selectableAgents.length === 0) {
|
||||
<mat-hint class="dte-hint--missing">No active agent on file</mat-hint>
|
||||
} @else if (pickedAgentIsInactive) {
|
||||
<!-- Only reachable on a settlement: this agent no longer collects, but the cash they
|
||||
already hold still has to be able to reach a branch. -->
|
||||
<mat-hint class="dte-hint--missing">This agent no longer collects — settling held cash only</mat-hint>
|
||||
} @else if (agentInfo.name) {
|
||||
<mat-hint class="dte-hint--found">
|
||||
{{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-stroked-button type="button" (click)="loadAgent()"><i class="fas fa-search"></i> Load</button>
|
||||
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Date</mat-label>
|
||||
<input matInput required [matDatepicker]="txnDatePicker" formControlName="transactionDate" />
|
||||
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
|
||||
the agent wrote down for that day, so changing it has to fetch that day's list. -->
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
[matDatepicker]="txnDatePicker"
|
||||
formControlName="transactionDate"
|
||||
(dateChange)="loadPendingEntries()"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #txnDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -61,75 +130,61 @@
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Tran. Type</mat-label>
|
||||
<mat-select required formControlName="transactionType">
|
||||
@for (type of transactionTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
@for (type of transactionTypes; track type.label) {
|
||||
<mat-option [value]="type.label" [disabled]="!type.supported">
|
||||
{{ type.label }}@if (!type.supported) {<span class="dte-soon"> — not available yet</span>}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (isPartyTran) {
|
||||
<!--
|
||||
What the agent wrote down on their round, waiting to be counted.
|
||||
|
||||
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
|
||||
customer was credited the moment a line was keyed, whether or not the cash behind it ever
|
||||
reached the branch. The agent records their own collections now, and what the clerk does here
|
||||
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
|
||||
-->
|
||||
@if (isPartyTran && agentInfo.name) {
|
||||
<div class="dte-section">
|
||||
<div class="dte-section__header">
|
||||
<i class="fas fa-list"></i>
|
||||
<h3>Today's Collections</h3>
|
||||
<button mat-stroked-button type="button" class="dte-add-row" (click)="addCollectionRow()">
|
||||
<i class="fas fa-plus"></i> Add Row
|
||||
<h3>Collections to settle</h3>
|
||||
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (collections.length === 0) {
|
||||
<p class="dte-empty">No collection rows added yet.</p>
|
||||
}
|
||||
@if (pendingLoading) {
|
||||
<p class="dte-empty">Reading this agent's entries...</p>
|
||||
} @else if (pendingEntries.length === 0) {
|
||||
<p class="dte-empty">
|
||||
{{ agentInfo.name }} has written nothing down for this date. Nothing to settle.
|
||||
</p>
|
||||
} @else {
|
||||
@for (entry of pendingEntries; track entry.id) {
|
||||
<div class="dte-pending-row">
|
||||
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
|
||||
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
|
||||
<span class="dte-pending-row__mode">{{ entry.transactionMode }}</span>
|
||||
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (row of collections.controls; track row; let i = $index) {
|
||||
<div class="dte-collection-row" [formGroup]="$any(row)">
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Party Code</mat-label>
|
||||
<input matInput required formControlName="partyCode" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>TRTP</mat-label>
|
||||
<mat-select required formControlName="transactionMode">
|
||||
@for (mode of transactionModes; track mode) {
|
||||
<mat-option [value]="mode">{{ mode }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>AC Type</mat-label>
|
||||
<input matInput formControlName="accountType" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>A/C No.</mat-label>
|
||||
<input matInput required formControlName="accountNo" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Amount</mat-label>
|
||||
<input matInput required type="number" formControlName="amount" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Cheque No</mat-label>
|
||||
<input matInput formControlName="chequeNo" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Token No</mat-label>
|
||||
<input matInput formControlName="tokenNo" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
|
||||
<mat-label>Description</mat-label>
|
||||
<input matInput formControlName="description" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="dte-remove-row"
|
||||
(click)="removeCollectionRow(i)"
|
||||
matTooltip="Remove row"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<div class="dte-total">
|
||||
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
|
||||
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The figure the clerk counts against, said in words rather than left as a column total -
|
||||
it is the one number they are acting on, and the rest of the panel is working out.
|
||||
-->
|
||||
<p class="dte-expected">
|
||||
Take <strong>{{ pendingTotal | number: '1.2-2' }}</strong> from {{ agentInfo.name }}, then post.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -232,8 +287,16 @@
|
||||
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
|
||||
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="entryForm.invalid" (click)="save()">
|
||||
Save
|
||||
<!-- On a Party Tran there is nothing to save until the agent has written something down, so
|
||||
the button says what it will do and stays out of reach until it can do it. -->
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="entryForm.invalid || (isPartyTran && pendingEntries.length === 0)"
|
||||
(click)="save()"
|
||||
>
|
||||
{{ isPartyTran ? 'Confirm & Post' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
$primary: #0f766e;
|
||||
|
||||
.dte-container {
|
||||
max-width: 1080px;
|
||||
// Wider than a normal form page: a collection row carries six fields side by side, and a clerk
|
||||
// keying a round should see the whole of it.
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.dte-header {
|
||||
@@ -93,6 +95,7 @@ $primary: #0f766e;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare.
|
||||
.dte-field {
|
||||
min-width: 180px;
|
||||
|
||||
@@ -101,9 +104,18 @@ $primary: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the collection grid the track decides the width, not the field. Left to its own 180px
|
||||
// floor a field refuses to shrink into a narrower track and overruns the one beside it.
|
||||
.dte-collection-row .dte-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dte-section {
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
// A very narrow window scrolls the grid sideways rather than the whole page.
|
||||
overflow-x: auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
@@ -140,9 +152,21 @@ $primary: #0f766e;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
* One collection per row. Fixed columns rather than an even split, so the row holds its shape as
|
||||
* fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party
|
||||
* picker gets the width its "code · name · daily amount" needs.
|
||||
*/
|
||||
.dte-collection-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr) auto;
|
||||
grid-template-columns:
|
||||
minmax(0, 2.2fr) /* Party */
|
||||
minmax(0, 1fr) /* TRTP */
|
||||
minmax(0, 1fr) /* AC Type */
|
||||
minmax(0, 1.4fr) /* A/C No. */
|
||||
minmax(0, 1fr) /* Amount */
|
||||
minmax(0, 1.6fr) /* Description */
|
||||
auto; /* remove */
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
@@ -151,12 +175,27 @@ $primary: #0f766e;
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
// Cheque No and Token No push Description and the remove button onto a second line, which is
|
||||
// what a non-cash entry should look like rather than a row running off the screen.
|
||||
&:has(.dte-field--instrument) {
|
||||
grid-template-columns:
|
||||
minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)
|
||||
minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-collection-row .dte-field--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.dte-collection-row,
|
||||
.dte-collection-row:has(.dte-field--instrument) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-remove-row {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
@@ -185,3 +224,172 @@ $primary: #0f766e;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolved name under an Agent / Party Code field — the check that the right record was found. */
|
||||
.dte-hint--found {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dte-hint--missing {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Marks an agent in the list who is no longer collecting - only offered for settlement. */
|
||||
.dte-agent-off {
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* The figure the clerk actually verifies against the passbook before saving. */
|
||||
.dte-after {
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
/* What the agent should be handing over for this round. */
|
||||
.dte-total {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: 10px 4px 2px;
|
||||
border-top: 1px solid rgb(0 0 0 / 10%);
|
||||
font-size: 0.88rem;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
|
||||
strong {
|
||||
font-size: 1.1rem;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */
|
||||
.dte-soon {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Marks a party this agent has already collected from today, on the row where it is picked.
|
||||
*
|
||||
* Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once -
|
||||
* so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a
|
||||
* whole round be credited twice.
|
||||
*/
|
||||
.dte-hint--again {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Where a party stands against its instalments. Three states rather than one colour, because they
|
||||
* call for three different things from the clerk: ahead means take nothing, behind means take more
|
||||
* than the daily amount, level means take the daily amount as usual.
|
||||
*
|
||||
* Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the
|
||||
* agent's job to recover, not an error. Red here would cry wolf on most rows.
|
||||
*/
|
||||
.dte-hint--ahead,
|
||||
.dte-sched--ahead {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dte-hint--behind,
|
||||
.dte-sched--behind {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dte-hint--level,
|
||||
.dte-sched--level {
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
/* In the party panel the schedule line carries more weight than the fields around it. */
|
||||
.dte-sched {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */
|
||||
.dte-covered {
|
||||
color: #15803d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary
|
||||
* and the agent's job to recover, while this is a figure that probably was not handed over at all,
|
||||
* and once saved it cannot be taken back. It still does not block - the clerk decides.
|
||||
*/
|
||||
.dte-hint--overpay {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
The agent's written-down round, as the counter reads it.
|
||||
|
||||
Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here,
|
||||
only a list to check a bag of cash against, and controls would invite editing something the clerk
|
||||
has no business editing.
|
||||
*/
|
||||
.dte-pending-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 10px 2px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-weight: 600;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__mode {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Amounts are read down the column and compared, so the digits have to line up. */
|
||||
&__amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
min-width: 96px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */
|
||||
.dte-expected {
|
||||
margin: 14px 2px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
|
||||
/** Custom Components */
|
||||
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
|
||||
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
@@ -40,10 +47,12 @@ import { Dates } from 'app/core/utils/dates';
|
||||
styleUrls: ['./daily-transaction-entry.component.scss'],
|
||||
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||
})
|
||||
export class DailyTransactionEntryComponent {
|
||||
export class DailyTransactionEntryComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private router = inject(Router);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private alertService = inject(AlertService);
|
||||
private dialog = inject(MatDialog);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
@@ -53,13 +62,18 @@ export class DailyTransactionEntryComponent {
|
||||
'Withdraw Deposite': 'WITHDRAW_DEPOSITE'
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy screen's six types. Only the three the backend actually distinguishes can be picked -
|
||||
* the rest are shown but not selectable, so the screen stays honest about what the system does
|
||||
* today instead of accepting an entry it will reject on save.
|
||||
*/
|
||||
transactionTypes = [
|
||||
'Party Tran',
|
||||
'Agent Tran',
|
||||
'Party Payment',
|
||||
'OD Entry',
|
||||
'OD Payment',
|
||||
'Withdraw Deposite'
|
||||
{ label: 'Party Tran', supported: true },
|
||||
{ label: 'Agent Tran', supported: true },
|
||||
{ label: 'Withdraw Deposite', supported: true },
|
||||
{ label: 'Party Payment', supported: false },
|
||||
{ label: 'OD Entry', supported: false },
|
||||
{ label: 'OD Payment', supported: false }
|
||||
];
|
||||
|
||||
transactionModes = [
|
||||
@@ -129,6 +143,263 @@ export class DailyTransactionEntryComponent {
|
||||
currentBalance: 0
|
||||
};
|
||||
|
||||
agentLookupFailed = false;
|
||||
|
||||
/** Every agent still on the books - picked from a list rather than keyed by id. */
|
||||
agents: any[] = [];
|
||||
|
||||
/**
|
||||
* The picked agent's parties, fetched once when the agent is chosen. Every party code typed into
|
||||
* the grid is resolved from here, so a clerk keying a round of thirty collections waits on the
|
||||
* network once rather than thirty times - and a code belonging to another agent's party simply
|
||||
* will not resolve, which is the check that matters.
|
||||
*/
|
||||
agentParties: any[] = [];
|
||||
|
||||
/**
|
||||
* The party behind the row being worked on, shown in the panel beside the agent's - who this
|
||||
* collection is for, what they owe daily, and what their balance becomes once it is saved.
|
||||
*/
|
||||
partyInfo: any = null;
|
||||
|
||||
/** Which grid row the party panel is showing. */
|
||||
activeRow = -1;
|
||||
|
||||
/**
|
||||
* What has already been keyed for this agent on the chosen date.
|
||||
*
|
||||
* The real risk on this screen is not a wrong figure, it is the same collection book being keyed
|
||||
* twice - an interruption mid-round, and every customer in it gets credited again while the agent
|
||||
* appears to be holding cash he already handed over. Showing the round back removes the guesswork.
|
||||
*/
|
||||
todaysEntries: any[] = [];
|
||||
|
||||
get todaysTotal(): number {
|
||||
return this.todaysEntries.reduce((sum, entry) => sum + Number(entry.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What this party has already been collected for on this date, if anything.
|
||||
*
|
||||
* Shown on the row itself rather than in a summary above it. A customer paying twice in a day is
|
||||
* legitimate - arrears, or two days at once - so this never blocks the entry. It only has to be
|
||||
* where the clerk is already looking at the moment they pick the party, which a panel at the top
|
||||
* of the screen is not.
|
||||
*/
|
||||
alreadyCollected(index: number): number {
|
||||
const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || '');
|
||||
if (!partyCode) {
|
||||
return 0;
|
||||
}
|
||||
return this.todaysEntries
|
||||
.filter((entry) => String(entry.partyCode) === partyCode)
|
||||
.reduce((sum, entry) => sum + Number(entry.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a party stands against the instalments it owes, in the words the branch uses.
|
||||
*
|
||||
* The running balance says how much has been paid, never whether it was paid on time - a customer
|
||||
* four days ahead and one eleven days behind can hold the same figure. This is what the clerk needs
|
||||
* at the moment they pick the party, so it is worked out per row rather than shown in a summary.
|
||||
*
|
||||
* A shortfall is split in two because the bank treats the halves differently: what is still inside
|
||||
* the catch-up window can be collected today, and what has fallen outside it is gone. Putting them
|
||||
* together would have the clerk asking for money the bank has already written off.
|
||||
*/
|
||||
schedule(party: any): { state: 'ahead' | 'behind' | 'level'; text: string } | null {
|
||||
const daily = Number(party?.dailyDepositAmount || 0);
|
||||
if (!party || daily <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const advance = Number(party.advanceAmount || 0);
|
||||
if (advance > 0) {
|
||||
return { state: 'ahead', text: `${this.money(advance)} already paid ahead` };
|
||||
}
|
||||
|
||||
// What to ask for today is the recoverable shortfall exactly as it stands - today's own
|
||||
// instalment is already inside it, because the schedule counts the first instalment on the
|
||||
// opening day itself. Adding the daily on top would charge today twice, which on an account
|
||||
// opened this morning reads as two days owed on its first day.
|
||||
//
|
||||
// Instalments past the catch-up window are deliberately left out of both the figure and the
|
||||
// words - they cannot be collected, so naming them would only have the clerk asking for money
|
||||
// the bank has already written off, and reading a shortfall the amount does not cover.
|
||||
const recoverable = Number(party.recoverableAmount || 0);
|
||||
if (recoverable > 0) {
|
||||
return { state: 'behind', text: `Take ${this.money(recoverable)} to complete today` };
|
||||
}
|
||||
|
||||
return { state: 'level', text: 'Up to date' };
|
||||
}
|
||||
|
||||
private money(value: number): string {
|
||||
return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** The party on a given collection row, so the row can show where that party stands. */
|
||||
partyOfRow(index: number): any {
|
||||
const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || '');
|
||||
return partyCode ? this.agentParties.find((p) => String(p.partyCode) === partyCode) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Says so plainly when the agent just picked has nobody to collect from.
|
||||
*
|
||||
* An empty party list looks identical to a list that has not loaded, and a clerk who reads it as
|
||||
* the second will sit waiting for names that are never coming. Raised as a dialog rather than a
|
||||
* line under the field because it means this agent cannot be used for a round at all - there is
|
||||
* nothing to key, and the clerk should go back and pick another.
|
||||
*/
|
||||
private warnIfAgentHasNoParties(): void {
|
||||
if (this.agentParties.length > 0) {
|
||||
return;
|
||||
}
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
const agent = this.agents.find((a: any) => String(a.id) === String(agentId));
|
||||
this.dialog.open(NoticeDialogComponent, {
|
||||
data: {
|
||||
heading: 'No customers for this agent',
|
||||
// Named for what is actually missing. The agent's own settlement account is a different
|
||||
// thing and is usually there - saying "no accounts" sends whoever reads it to check the
|
||||
// wrong record.
|
||||
message:
|
||||
`No customer accounts are assigned to ${agent?.agentCode || 'this agent'}, so there is nothing ` +
|
||||
'to collect. Assign customers to this agent first, or choose a different agent.'
|
||||
},
|
||||
width: '420px',
|
||||
autoFocus: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a row for days other than today - paid ahead, or owed from days gone by.
|
||||
*
|
||||
* Kept off the ordinary row and put behind its own button, because most collections are simply
|
||||
* today's instalment: a box asking about days would sit empty on nearly every row, and a stray
|
||||
* value left in one would charge a customer for days they did not pay for.
|
||||
*
|
||||
* The row it adds is filled in and left in the list rather than posted, so it is saved with the
|
||||
* rest of the round and the clerk sees it beside the others before committing.
|
||||
*/
|
||||
addDayBasedRow(mode: 'advance' | 'previous'): void {
|
||||
const dialogRef = this.dialog.open(DayCollectionDialogComponent, {
|
||||
data: { mode, parties: this.agentParties },
|
||||
width: '460px',
|
||||
autoFocus: false
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((picked: any) => {
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
this.addCollectionRow();
|
||||
const row = this.collections.at(this.collections.length - 1);
|
||||
row.get('partyCode')?.setValue(picked.partyCode);
|
||||
this.loadPartyForRow(this.collections.length - 1);
|
||||
row.get('amount')?.setValue(picked.amount);
|
||||
row.get('advanceDays')?.setValue(picked.advanceDays ?? '');
|
||||
row.get('arrearsDays')?.setValue(picked.arrearsDays ?? '');
|
||||
row.get('description')?.setValue(
|
||||
`${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}`
|
||||
);
|
||||
this.recalculateRowBalance(this.collections.length - 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Questions an amount on an ordinary row that is not the day's instalment.
|
||||
*
|
||||
* Paying for other days now has its own buttons, and each works out the amount from the scheme
|
||||
* itself. What is left in the plain Amount box is one thing: today. Anything else is either a
|
||||
* mistyped figure - a zero too many turns a hundred rupees into a thousand - or days being keyed by
|
||||
* hand, which is the very thing the buttons exist to stop.
|
||||
*
|
||||
* It warns and does not block. A customer can hand over an odd amount, and the clerk is the one who
|
||||
* saw them do it; what they need is to be told before saving, because a collection cannot be taken
|
||||
* back afterwards - it can only be carried into the days that follow.
|
||||
*/
|
||||
overpayWarning(index: number): string | null {
|
||||
const party = this.partyOfRow(index);
|
||||
const daily = Number(party?.dailyDepositAmount || 0);
|
||||
const amount = Number(this.collections.at(index)?.get('amount')?.value || 0);
|
||||
if (!party || daily <= 0 || amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never raised on a row the Advance or Previous Days button filled in: those figures are the
|
||||
// scheme's own and the server checks them, so they cannot be the slip this is looking for.
|
||||
const row = this.collections.at(index);
|
||||
if (row?.get('advanceDays')?.value || row?.get('arrearsDays')?.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (amount === daily) {
|
||||
return null;
|
||||
}
|
||||
if (amount > daily) {
|
||||
return (
|
||||
`${this.money(amount)} is more than the daily ${this.money(daily)}. ` +
|
||||
'Use Advance to pay ahead, or Previous Days to clear arrears.'
|
||||
);
|
||||
}
|
||||
return `${this.money(amount)} is less than the daily ${this.money(daily)}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this party is already paid ahead far enough that today is covered.
|
||||
*
|
||||
* The agent still calls - the round does not skip a door - but there is nothing to collect, so the
|
||||
* row is closed to entry rather than hidden. A party that vanished from the list would have the
|
||||
* clerk wondering what had happened to it.
|
||||
*/
|
||||
isCoveredByAdvance(party: any): boolean {
|
||||
return Number(party?.advanceDays || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's own written-down collections for the chosen date, and whether they are being read.
|
||||
*
|
||||
* Held rather than typed. A Party Tran is no longer something the clerk composes - the agent
|
||||
* composed it on their round, and this screen shows it back so the cash can be counted against it.
|
||||
*/
|
||||
pendingEntries: any[] = [];
|
||||
pendingLoading = false;
|
||||
|
||||
/** What the agent should be handing over: their own entries, added up. */
|
||||
get pendingTotal(): number {
|
||||
return this.pendingEntries.reduce((sum: number, entry: any) => sum + Number(entry.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what this agent has outstanding for the date on the form.
|
||||
*
|
||||
* Asked again whenever the agent or the date changes, because both pick out a different day's
|
||||
* round - and a list left over from the previous choice is a list the clerk might settle.
|
||||
*/
|
||||
loadPendingEntries(): void {
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
const date = this.entryForm.get('transactionDate')?.value;
|
||||
if (!agentId || !date) {
|
||||
this.pendingEntries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingLoading = true;
|
||||
const on = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
|
||||
this.organizationService.getAgentCollectionDay(agentId, on).subscribe({
|
||||
next: (summary: any) => {
|
||||
this.pendingEntries = (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING');
|
||||
this.pendingLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.pendingEntries = [];
|
||||
this.pendingLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isPartyTran(): boolean {
|
||||
return this.entryForm.get('transactionType').value === 'Party Tran';
|
||||
}
|
||||
@@ -146,7 +417,7 @@ export class DailyTransactionEntryComponent {
|
||||
}
|
||||
|
||||
private buildCollectionRow(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
const row = this.formBuilder.group({
|
||||
partyCode: [
|
||||
'',
|
||||
Validators.required
|
||||
@@ -164,6 +435,14 @@ export class DailyTransactionEntryComponent {
|
||||
0,
|
||||
Validators.required
|
||||
],
|
||||
/*
|
||||
* Days taken in advance, when the customer is paying ahead rather than for today. Empty on an
|
||||
* ordinary row. The amount follows from it - the scheme's daily instalment times the days - so
|
||||
* the clerk picks days, not rupees, and cannot quote a figure the scheme does not charge.
|
||||
*/
|
||||
advanceDays: [''],
|
||||
/* Days of arrears being cleared. Same idea as advanceDays, the other way round. */
|
||||
arrearsDays: [''],
|
||||
chequeNo: [''],
|
||||
tokenNo: [''],
|
||||
description: [''],
|
||||
@@ -180,6 +459,17 @@ export class DailyTransactionEntryComponent {
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Cash has no instrument behind it, so switching back to cash drops anything typed against a
|
||||
// cheque or clearing entry rather than saving a stale number with it.
|
||||
row.get('transactionMode')?.valueChanges.subscribe((mode) => {
|
||||
if (mode === 'Cash') {
|
||||
row.get('chequeNo')?.setValue('', { emitEvent: false });
|
||||
row.get('tokenNo')?.setValue('', { emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
addCollectionRow(): void {
|
||||
@@ -190,28 +480,163 @@ export class DailyTransactionEntryComponent {
|
||||
this.collections.removeAt(index);
|
||||
}
|
||||
|
||||
loadAgent(): void {
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
this.organizationService.getAgent(agentId).subscribe((agent: any) => {
|
||||
const guarantors = agent.guarantors || [];
|
||||
this.agentInfo = {
|
||||
name: agent.agentNameId || '',
|
||||
address: agent.agentAddress1 || '',
|
||||
city: agent.agentCity || '',
|
||||
accountNo: agent.agentSavingAccountNo || '',
|
||||
guarantor1: guarantors[0]?.guarantorName || '',
|
||||
guarantor2: guarantors[1]?.guarantorName || '',
|
||||
currentBalance: agent.agentCurrentBalance || 0
|
||||
};
|
||||
ngOnInit(): void {
|
||||
// Closed agents are off the books entirely; everyone else is kept, and which of them can be
|
||||
// picked depends on the transaction type - see selectableAgents.
|
||||
this.organizationService.getAgents().subscribe({
|
||||
next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)),
|
||||
error: () => (this.agents = [])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents that can be picked for the transaction type currently chosen.
|
||||
*
|
||||
* A deactivated agent no longer collects, so they are not offered for a collection or a transfer.
|
||||
* Settlement is the exception: an agent switched off mid-round is still holding the cash they
|
||||
* already took, and that cash has to be able to reach a branch. Leaving them out of the settlement
|
||||
* list would strand it with no way to hand it in.
|
||||
*/
|
||||
get selectableAgents(): any[] {
|
||||
if (this.isAgentTran) {
|
||||
return this.agents;
|
||||
}
|
||||
return this.agents.filter((agent: any) => agent.active);
|
||||
}
|
||||
|
||||
/** Flags a picked agent that is no longer collecting, so the entry is not made by mistake. */
|
||||
get pickedAgentIsInactive(): boolean {
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
if (!agentId) {
|
||||
return false;
|
||||
}
|
||||
const picked = this.agents.find((agent: any) => agent.id === agentId);
|
||||
return !!picked && !picked.active;
|
||||
}
|
||||
|
||||
/** Whether this row needs a cheque or token number - a cash collection has neither. */
|
||||
needsInstrument(index: number): boolean {
|
||||
return this.collections.at(index)?.get('transactionMode')?.value !== 'Cash';
|
||||
}
|
||||
|
||||
loadAgent(): void {
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
this.agentLookupFailed = false;
|
||||
this.partyInfo = null;
|
||||
this.activeRow = -1;
|
||||
if (!agentId) {
|
||||
this.agentParties = [];
|
||||
this.pendingEntries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.organizationService.getAgent(agentId).subscribe({
|
||||
next: (agent: any) => {
|
||||
const guarantors = agent.guarantors || [];
|
||||
this.agentInfo = {
|
||||
name: agent.agentNameId || '',
|
||||
address: agent.agentAddress1 || '',
|
||||
city: agent.agentCity || '',
|
||||
accountNo: agent.agentSavingAccountNo || '',
|
||||
guarantor1: guarantors[0]?.guarantorName || '',
|
||||
guarantor2: guarantors[1]?.guarantorName || '',
|
||||
currentBalance: agent.agentCurrentBalance || 0
|
||||
};
|
||||
},
|
||||
error: () => {
|
||||
this.agentLookupFailed = true;
|
||||
this.agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
this.organizationService.getPartiesOfAgent(agentId).subscribe({
|
||||
next: (parties: any[]) => {
|
||||
this.agentParties = parties || [];
|
||||
this.warnIfAgentHasNoParties();
|
||||
},
|
||||
error: () => (this.agentParties = [])
|
||||
});
|
||||
|
||||
this.loadTodaysEntries();
|
||||
this.loadPendingEntries();
|
||||
}
|
||||
|
||||
/** This agent's collections already recorded for the date on the screen. */
|
||||
loadTodaysEntries(): void {
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
if (!agentId) {
|
||||
this.todaysEntries = [];
|
||||
return;
|
||||
}
|
||||
const onDate = this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd');
|
||||
|
||||
this.organizationService.getPartyTransactionsOfAgent(agentId).subscribe({
|
||||
next: (transactions: any[]) => {
|
||||
this.todaysEntries = (transactions || []).filter(
|
||||
(transaction: any) =>
|
||||
transaction.transactionType === 'PARTY_TRAN'
|
||||
&& this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate
|
||||
);
|
||||
},
|
||||
error: () => (this.todaysEntries = [])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the party code typed into a row. A code this agent does not collect for is left
|
||||
* unresolved rather than fetched anyway - an agent posting against someone else's party is the
|
||||
* mistake this screen most needs to catch.
|
||||
*/
|
||||
loadPartyForRow(index: number): void {
|
||||
const row = this.collections.at(index);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const partyCode = String(row.get('partyCode')?.value || '').trim();
|
||||
this.activeRow = index;
|
||||
|
||||
const party = this.agentParties.find((candidate) => String(candidate.partyCode) === partyCode);
|
||||
this.partyInfo = party || null;
|
||||
|
||||
row.get('partyName')?.setValue(party?.partyName || '', { emitEvent: false });
|
||||
// The account credited is the party's own linked savings account, never keyed by hand.
|
||||
row.get('accountNo')?.setValue(party?.creditToAccount || party?.partyCode || '', { emitEvent: false });
|
||||
row.get('accountType')?.setValue(party?.creditToAccountType || 'Savings', { emitEvent: false });
|
||||
|
||||
this.recalculateRowBalance(index);
|
||||
}
|
||||
|
||||
/** The party's balance once this row is saved - what the legacy screen called Bal. After This Tr. */
|
||||
recalculateRowBalance(index: number): void {
|
||||
const row = this.collections.at(index);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const party = this.agentParties.find(
|
||||
(candidate) => String(candidate.partyCode) === String(row.get('partyCode')?.value || '').trim()
|
||||
);
|
||||
const current = Number(party?.currentBalance || 0);
|
||||
const amount = Number(row.get('amount')?.value || 0);
|
||||
row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false });
|
||||
}
|
||||
|
||||
/** The balance the party panel shows - live against whatever the active row currently holds. */
|
||||
get partyBalanceAfter(): number {
|
||||
if (!this.partyInfo || this.activeRow < 0) {
|
||||
return 0;
|
||||
}
|
||||
const row = this.collections.at(this.activeRow);
|
||||
return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0);
|
||||
}
|
||||
|
||||
/** Total of the round being keyed in - what the agent should be handing over. */
|
||||
get collectionTotal(): number {
|
||||
return this.collections.controls.reduce((sum, row) => sum + Number(row.get('amount')?.value || 0), 0);
|
||||
}
|
||||
|
||||
/** Re-reads what has already been keyed for this agent on the chosen date. */
|
||||
retrieve(): void {
|
||||
// No "my assigned parties" read API exists yet — this will pre-fill
|
||||
// the collections grid with today's assigned parties once available.
|
||||
this.loadTodaysEntries();
|
||||
}
|
||||
|
||||
viewLedger(): void {
|
||||
@@ -231,11 +656,65 @@ export class DailyTransactionEntryComponent {
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.router.navigate(['/accounts/savings']);
|
||||
this.router.navigate(['/accounts/dds']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save, asking first when what is about to happen is a settlement.
|
||||
*
|
||||
* A settlement credits every customer on the list and records the cash as the agent's, in one
|
||||
* act that cannot be taken back - so the screen says the figure out loud and waits. The question
|
||||
* is the one the clerk is actually answering at the counter: is this much really in front of me.
|
||||
*
|
||||
* The double-entry guard this used to carry has moved to where the entries are now made. Two
|
||||
* lines for one account on one day are refused on the agent's own screen, and refused again by
|
||||
* the server when the day is settled; by the time a clerk sees the list there is nothing left to
|
||||
* warn about.
|
||||
*/
|
||||
save(): void {
|
||||
if (!this.isPartyTran) {
|
||||
this.postTransaction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const money = Number(this.pendingTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 });
|
||||
const count = this.pendingEntries.length;
|
||||
|
||||
this.dialog
|
||||
.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
type: 'warn',
|
||||
heading: 'Settle these collections',
|
||||
dialogContext:
|
||||
`Take ${money} from ${this.agentInfo.name || 'the agent'} and post `
|
||||
+ `${count} ${count === 1 ? 'collection' : 'collections'}? `
|
||||
+ 'Every customer on the list is credited and the cash is recorded against the agent. '
|
||||
+ 'This cannot be undone.'
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((response: any) => {
|
||||
if (response?.confirm) {
|
||||
this.postTransaction();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private postTransaction(): void {
|
||||
const raw = this.entryForm.getRawValue();
|
||||
|
||||
// A Party Tran is no longer a transaction this screen composes. The agent wrote the round down
|
||||
// on their own; what happens here is that it is settled, all of it at once, through a call of
|
||||
// its own. Everything else on this screen still posts a transaction the way it always did.
|
||||
if (this.isPartyTran) {
|
||||
this.settleCollections();
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType];
|
||||
@@ -248,18 +727,7 @@ export class DailyTransactionEntryComponent {
|
||||
locale
|
||||
};
|
||||
|
||||
if (this.isPartyTran) {
|
||||
payload.collections = (raw.collections || []).map((row: any) => ({
|
||||
partyCode: row.partyCode,
|
||||
transactionMode: row.transactionMode,
|
||||
accountType: row.accountType || undefined,
|
||||
accountNo: row.accountNo,
|
||||
amount: row.amount,
|
||||
chequeNo: row.chequeNo || undefined,
|
||||
tokenNo: row.tokenNo || undefined,
|
||||
description: row.description || undefined
|
||||
}));
|
||||
} else if (this.isWithdrawDeposite) {
|
||||
if (this.isWithdrawDeposite) {
|
||||
payload.creditorPartyCode = raw.creditorPartyCode;
|
||||
payload.debitPartyCode = raw.debitPartyCode;
|
||||
payload.withdrawAmount = raw.withdrawAmount;
|
||||
@@ -272,8 +740,19 @@ export class DailyTransactionEntryComponent {
|
||||
payload.description = raw.settlementDescription || undefined;
|
||||
}
|
||||
|
||||
// What was just posted, captured before the form is cleared, so the confirmation can name it.
|
||||
const posted = this.postedSummary(raw);
|
||||
|
||||
this.organizationService.createPartyTransaction(payload).subscribe(() => {
|
||||
// On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk
|
||||
// who looks away reasonably assumes it did not go through and posts the whole round again.
|
||||
this.alertService.alert({ type: 'Success', message: posted });
|
||||
|
||||
this.collections.clear();
|
||||
this.partyInfo = null;
|
||||
this.activeRow = -1;
|
||||
// The round just saved becomes part of what the screen shows back.
|
||||
this.loadTodaysEntries();
|
||||
this.entryForm.patchValue({
|
||||
creditorPartyCode: '',
|
||||
debitPartyCode: '',
|
||||
@@ -287,4 +766,66 @@ export class DailyTransactionEntryComponent {
|
||||
this.loadAgent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the agent's day over: every entry they wrote down for this date becomes a real credit.
|
||||
*
|
||||
* No amount is sent and none is asked for on screen. What settles is what the entries say - a
|
||||
* figure typed at the counter would be a second opinion about a total the entries already give,
|
||||
* and the two could differ. The clerk's part is to count the cash against that total and agree.
|
||||
*
|
||||
* All or none. A part-settled round would leave the branch holding cash against a list that no
|
||||
* longer matched it, which is a worse position than simply running it again.
|
||||
*/
|
||||
private settleCollections(): void {
|
||||
const raw = this.entryForm.getRawValue();
|
||||
if (this.pendingEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = this.pendingEntries.length;
|
||||
const total = this.pendingTotal;
|
||||
const agent = this.agentInfo.name || 'agent';
|
||||
|
||||
this.organizationService
|
||||
.settleAgentCollections({
|
||||
agentId: Number(raw.agentId),
|
||||
entryDate: this.dateUtils.formatDate(raw.transactionDate, 'yyyy-MM-dd'),
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: this.settingsService.language.code
|
||||
})
|
||||
.subscribe(() => {
|
||||
const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 });
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money
|
||||
});
|
||||
|
||||
// The list empties because those entries are settled now, and the agent's cash goes up by
|
||||
// what was just taken - both are read again rather than worked out on screen.
|
||||
this.loadPendingEntries();
|
||||
this.loadTodaysEntries();
|
||||
this.loadAgent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Says what was actually posted, in the terms the clerk was working in - a count and a total for a
|
||||
* round of collections, the amount and who it came from for anything else. "Saved successfully"
|
||||
* would confirm that something happened without confirming that the right thing happened.
|
||||
*/
|
||||
private postedSummary(raw: any): string {
|
||||
const money = (value: any) => Number(value || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 });
|
||||
const agent = this.agentInfo.name || 'agent';
|
||||
|
||||
if (this.isPartyTran) {
|
||||
const rows = raw.collections || [];
|
||||
const total = rows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0);
|
||||
return `${rows.length} ${rows.length === 1 ? 'collection' : 'collections'} posted for ${agent} — ${money(total)}`;
|
||||
}
|
||||
if (this.isAgentTran) {
|
||||
return `${agent} settled ${money(raw.settlementAmount)} to the branch`;
|
||||
}
|
||||
return `${money(raw.withdrawAmount)} transferred from ${raw.debitPartyCode} to ${raw.creditorPartyCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="dcd-field">
|
||||
<mat-label>Party</mat-label>
|
||||
<mat-select [(ngModel)]="partyCode" (selectionChange)="partyChanged()">
|
||||
@for (p of eligibleParties; track p.id) {
|
||||
<mat-option [value]="p.partyCode">
|
||||
{{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (eligibleParties.length === 0) {
|
||||
<!-- Only reachable on the arrears side: nobody is behind within the window. -->
|
||||
<mat-hint class="dcd-none">No party has recoverable days</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="dcd-field">
|
||||
<mat-label>Days</mat-label>
|
||||
<mat-select [(ngModel)]="days" [disabled]="!partyCode">
|
||||
@for (d of dayOptions; track d) {
|
||||
<mat-option [value]="d">{{ d }} {{ d === 1 ? 'day' : 'days' }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (isAdvance) {
|
||||
<mat-hint>Up to 5 days</mat-hint>
|
||||
} @else if (partyCode) {
|
||||
<mat-hint>Only days still within the catch-up window</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!--
|
||||
What it comes to, shown before it is added rather than after. The clerk quotes this to the
|
||||
customer, so it has to be the scheme's own figure and has to be visible while deciding.
|
||||
-->
|
||||
@if (amount > 0) {
|
||||
<div class="dcd-amount">
|
||||
<span class="dcd-amount__figure">{{ amount | number: '1.2-2' }}</span>
|
||||
<span class="dcd-amount__basis">
|
||||
{{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="!canAdd" (click)="add()">Add</button>
|
||||
</mat-dialog-actions>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.dcd-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dcd-none {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/*
|
||||
* The figure the clerk quotes at the counter. Larger than the fields above it because it is the one
|
||||
* thing the customer is told, and it is shown with its basis so the clerk can say where it came from
|
||||
* rather than just naming a number.
|
||||
*/
|
||||
.dcd-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgb(15 118 110 / 8%);
|
||||
|
||||
&__figure {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__basis {
|
||||
font-size: 0.8rem;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
/**
|
||||
* Collecting for days other than today - either paid ahead, or owed from days gone by.
|
||||
*
|
||||
* Both are the same act from the counter's side: the customer is paying for a number of days, and
|
||||
* what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the
|
||||
* amount follows. Typing an amount instead would let the daily figure be worked around a little at a
|
||||
* time, and would quote the customer something the scheme does not charge.
|
||||
*
|
||||
* The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance.
|
||||
* Paying arrears is capped by what is still inside the catch-up window - older instalments are gone,
|
||||
* and offering them would have the clerk asking for money the bank has already written off.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-day-collection-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatSelectModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
],
|
||||
templateUrl: './day-collection-dialog.component.html',
|
||||
styleUrls: ['./day-collection-dialog.component.scss']
|
||||
})
|
||||
export class DayCollectionDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<DayCollectionDialogComponent>);
|
||||
/** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
|
||||
partyCode = '';
|
||||
days: number | '' = '';
|
||||
|
||||
get isAdvance(): boolean {
|
||||
return this.data?.mode === 'advance';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.isAdvance ? 'Collect in advance' : 'Collect previous days';
|
||||
}
|
||||
|
||||
get party(): any {
|
||||
return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parties this can be used on.
|
||||
*
|
||||
* Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still
|
||||
* inside the window - on an account that is up to date there is nothing to collect for past days,
|
||||
* and listing it would invite an entry that is really an advance in disguise.
|
||||
*/
|
||||
get eligibleParties(): any[] {
|
||||
const parties = this.data?.parties || [];
|
||||
return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days may be picked. Five ahead, matching the bank's allowance and the server's own
|
||||
* check; for arrears, only what has not yet fallen out of the catch-up window.
|
||||
*/
|
||||
get dayOptions(): number[] {
|
||||
const daily = Number(this.party?.dailyDepositAmount || 0);
|
||||
if (!this.party || daily <= 0) {
|
||||
return [];
|
||||
}
|
||||
const limit = this.isAdvance
|
||||
? 5
|
||||
: Math.floor(Number(this.party.recoverableAmount || 0) / daily);
|
||||
return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
get amount(): number {
|
||||
return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0);
|
||||
}
|
||||
|
||||
/** Clearing the days when the party changes - a count valid for one account is not for another. */
|
||||
partyChanged(): void {
|
||||
this.days = '';
|
||||
}
|
||||
|
||||
get canAdd(): boolean {
|
||||
return !!this.partyCode && Number(this.days) > 0 && this.amount > 0;
|
||||
}
|
||||
|
||||
add(): void {
|
||||
this.dialogRef.close({
|
||||
partyCode: this.partyCode,
|
||||
days: Number(this.days),
|
||||
amount: this.amount,
|
||||
// Sent as days, not just an amount: the server recomputes what they come to and checks it
|
||||
// against this account, so the figure quoted here is the one that gets posted.
|
||||
advanceDays: this.isAdvance ? Number(this.days) : undefined,
|
||||
arrearsDays: this.isAdvance ? undefined : Number(this.days)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="nd-head">
|
||||
<span class="nd-head__ic"><fa-icon icon="info-circle"></fa-icon></span>
|
||||
<h2 class="nd-head__title">{{ data.heading }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="nd-message">{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<!-- One button, because there is nothing here to agree to or call off. -->
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.nd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 24px 4px;
|
||||
|
||||
/*
|
||||
* Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here
|
||||
* and nothing is at risk - the clerk is being told a fact about the agent they picked, and a
|
||||
* warning colour would send them looking for a fault that is not there.
|
||||
*/
|
||||
&__ic {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgb(15 118 110 / 10%);
|
||||
color: #0f766e;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.nd-message {
|
||||
margin: 0;
|
||||
max-width: 44ch;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: rgb(0 0 0 / 70%);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/**
|
||||
* Something the clerk needs told, with nothing to decide.
|
||||
*
|
||||
* The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a
|
||||
* choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and
|
||||
* "Cancel" reads as though the thing being described could be undone. The error dialog has the right
|
||||
* single button but dresses the message in red, which turns "this agent has no parties" into a fault.
|
||||
*
|
||||
* So: one button, and the message stands on its own.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-notice-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './notice-dialog.component.html',
|
||||
styleUrls: ['./notice-dialog.component.scss']
|
||||
})
|
||||
export class NoticeDialogComponent {
|
||||
/** { heading, message } */
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -16,46 +16,59 @@
|
||||
|
||||
<div class="rv-card">
|
||||
<form [formGroup]="filterForm">
|
||||
<div class="rv-grid">
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>From Date</mat-label>
|
||||
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<!-- Only the filters this report's query actually reads are offered. One that changed nothing
|
||||
would read as a report ignoring what it was asked. -->
|
||||
@if (!hasNoFilters) {
|
||||
<div class="rv-grid">
|
||||
@if (usesDate) {
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>{{ dateLabel }} — from</mat-label>
|
||||
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>To Date</mat-label>
|
||||
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>{{ dateLabel }} — to</mat-label>
|
||||
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Agent From</mat-label>
|
||||
<input matInput type="number" formControlName="agentFrom" />
|
||||
</mat-form-field>
|
||||
@if (usesAgent) {
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Agent From</mat-label>
|
||||
<input matInput type="number" formControlName="agentFrom" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Agent To</mat-label>
|
||||
<input matInput type="number" formControlName="agentTo" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Agent To</mat-label>
|
||||
<input matInput type="number" formControlName="agentTo" />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Party From</mat-label>
|
||||
<input matInput type="number" formControlName="partyFrom" />
|
||||
</mat-form-field>
|
||||
@if (usesParty) {
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Party From</mat-label>
|
||||
<input matInput type="number" formControlName="partyFrom" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Party To</mat-label>
|
||||
<input matInput type="number" formControlName="partyTo" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="rv-field">
|
||||
<mat-label>Party To</mat-label>
|
||||
<input matInput type="number" formControlName="partyTo" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rv-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<!-- A report with nothing to fill in has already run itself; the button then only offers to
|
||||
fetch the figures again, which is what it is called. -->
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
|
||||
<i class="fas fa-play" style="margin-right: 6px"></i> Run Report
|
||||
<i class="fas" [class.fa-play]="!hasNoFilters" [class.fa-rotate-right]="hasNoFilters"></i>
|
||||
{{ hasNoFilters ? 'Refresh' : 'Run Report' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -66,25 +79,66 @@
|
||||
} @else if (rows.length === 0) {
|
||||
<p class="rv-empty">No rows matched these filters.</p>
|
||||
} @else {
|
||||
<div class="rv-table-scroll">
|
||||
<table class="rv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (column of columns; track column) {
|
||||
<th>{{ column }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row) {
|
||||
<!-- Laid out as the legacy statement printed it: a heading naming the report and the
|
||||
period, numbered rows, and a total under every money column. -->
|
||||
<div class="rv-statement">
|
||||
<div class="rv-statement__head">
|
||||
<h3 class="rv-statement__title">{{ reportName }}</h3>
|
||||
@if (usesDate) {
|
||||
<p class="rv-statement__period">
|
||||
{{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and
|
||||
{{ ranToDate | dateFormat }}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="rv-statement__period">as on {{ ranOnDate | dateFormat }}</p>
|
||||
}
|
||||
<button mat-stroked-button type="button" class="rv-print" (click)="print()">
|
||||
<i class="fas fa-print"></i> Print
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rv-table-scroll">
|
||||
<table class="rv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rv-sr">#</th>
|
||||
@for (column of columns; track column) {
|
||||
<td>{{ row[column] }}</td>
|
||||
<th [class.rv-num]="isAmountColumn(column)">{{ column }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track $index) {
|
||||
<tr>
|
||||
<td class="rv-sr">{{ $index + 1 }}</td>
|
||||
@for (column of columns; track column) {
|
||||
<td [class.rv-num]="isAmountColumn(column)">{{ cell(row, column) }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (hasTotalledColumn) {
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td class="rv-sr"></td>
|
||||
@for (column of columns; track column; let first = $first) {
|
||||
<td [class.rv-num]="isAmountColumn(column)">
|
||||
@if (first) {
|
||||
Total :
|
||||
} @else if (isTotalledColumn(column)) {
|
||||
{{ columnTotal(column) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="rv-statement__count">
|
||||
{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -94,6 +94,93 @@ $primary: #0f766e;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
/* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */
|
||||
.rv-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The row number down the left, as the printed statement carried it. */
|
||||
.rv-sr {
|
||||
width: 1%;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
font-style: italic;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
border-bottom: none;
|
||||
border-top: 2px solid rgb(0 0 0 / 20%);
|
||||
font-weight: 600;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the statement block: heading, table, footer count ---- */
|
||||
|
||||
.rv-action-bar i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rv-statement__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rv-statement__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rv-statement__period {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.rv-print {
|
||||
margin-left: auto;
|
||||
|
||||
i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.rv-statement__count {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Printing a statement should give the statement, not the screen around it. */
|
||||
@media print {
|
||||
.rv-header,
|
||||
.rv-grid,
|
||||
.rv-action-bar,
|
||||
.rv-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.rv-card {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rv-table-scroll {
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.rv-table {
|
||||
white-space: normal;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
@@ -37,7 +37,7 @@ import { Dates } from 'app/core/utils/dates';
|
||||
styleUrls: ['./report-viewer.component.scss'],
|
||||
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||
})
|
||||
export class ReportViewerComponent {
|
||||
export class ReportViewerComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
@@ -66,6 +66,41 @@ export class ReportViewerComponent {
|
||||
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
|
||||
};
|
||||
|
||||
/**
|
||||
* Which filters each report actually uses, and what its date range means.
|
||||
*
|
||||
* Every report used to be offered the same six filters whether it read them or not — so a date
|
||||
* range could be typed on the Agent Check List, which has no date in its query at all, and the
|
||||
* same rows would come back however it was set. A filter that changes nothing is worse than no
|
||||
* filter: it reads as a report that ignores what it was asked.
|
||||
*
|
||||
* Kept in step with the backend by hand; the source of truth is the switch in
|
||||
* `DdsReportReadPlatformServiceImpl.runReport`.
|
||||
*/
|
||||
private static readonly REPORT_FILTERS: Record<string, { date?: string; agent?: boolean; party?: boolean }> = {
|
||||
AGENT_CHECK_LIST: {},
|
||||
PARTY_CHECK_LIST: { agent: true, party: true },
|
||||
PARTY_BALANCE: { agent: true, party: true },
|
||||
AGENT_WISE_BALANCE: { agent: true },
|
||||
BALANCE_SUMMARY: { agent: true, party: true },
|
||||
MASTER_DATA_EXPORT: { agent: true, party: true },
|
||||
|
||||
AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true },
|
||||
COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true },
|
||||
DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true },
|
||||
AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true },
|
||||
SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true },
|
||||
|
||||
COMMISSION_REGISTER: { date: 'Posted between', agent: true },
|
||||
AGENT_COMMISSION_STATEMENT: { date: 'Posted between' },
|
||||
PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true },
|
||||
INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true },
|
||||
|
||||
TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true },
|
||||
CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true },
|
||||
MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true }
|
||||
};
|
||||
|
||||
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
|
||||
|
||||
hasRun = false;
|
||||
@@ -74,21 +109,198 @@ export class ReportViewerComponent {
|
||||
columns: string[] = [];
|
||||
rows: Array<Record<string, any>> = [];
|
||||
|
||||
/** The range the rows on screen were actually fetched for — not whatever the filter says now. */
|
||||
ranFromDate: Date | null = null;
|
||||
ranToDate: Date | null = null;
|
||||
|
||||
/** When a report carries no date range, the moment it was run is what dates its figures. */
|
||||
ranOnDate: Date | null = null;
|
||||
|
||||
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
private static readonly DATE = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
/**
|
||||
* One cell, rendered the way a printed statement shows it.
|
||||
*
|
||||
* Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them
|
||||
* as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures
|
||||
* against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric
|
||||
* (account numbers, codes) are left alone, since grouping them would corrupt them.
|
||||
*/
|
||||
cell(row: Record<string, any>, column: string): string {
|
||||
const value = row[column];
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2]));
|
||||
}
|
||||
if (this.isAmountColumn(column) && typeof value === 'number') {
|
||||
return ReportViewerComponent.MONEY.format(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a column holds money, and so should be right-aligned with two decimals.
|
||||
*
|
||||
* Decided from the column's name rather than its values: a count column and an account-number
|
||||
* column are both numbers, and neither reads as an amount.
|
||||
*/
|
||||
isAmountColumn(column: string): boolean {
|
||||
const name = column.toLowerCase();
|
||||
|
||||
// Ruled out first, because several of these also contain a money word: a rate is a percentage,
|
||||
// "Total Accounts" is a count that happens to say total, and an account number is an identifier
|
||||
// that would be corrupted by grouping.
|
||||
//
|
||||
// "agents" is matched exactly rather than as a substring: the two count columns are named
|
||||
// "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents"
|
||||
// are money, and a substring test swept those two up and printed them ungrouped.
|
||||
//
|
||||
// A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest",
|
||||
// which is enough for the money test below to claim it and put a meaningless total under it.
|
||||
if (
|
||||
name.includes('a/c no') ||
|
||||
name.includes('code') ||
|
||||
name.includes('rate') ||
|
||||
name.includes('%') ||
|
||||
name.includes('count') ||
|
||||
name.includes('entries') ||
|
||||
name.includes('accounts') ||
|
||||
name === 'total agents' ||
|
||||
name === 'active agents' ||
|
||||
name.includes('type') ||
|
||||
name.includes('pan')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
name.includes('amount') ||
|
||||
name.includes('balance') ||
|
||||
name.includes('collect') ||
|
||||
name.includes('commission') ||
|
||||
name.includes('deduction') ||
|
||||
name.includes('deposit') ||
|
||||
name.includes('advance') ||
|
||||
name.includes('interest') ||
|
||||
name.includes('tds') ||
|
||||
name.includes('total') ||
|
||||
name.includes('diff') ||
|
||||
name.includes('cash') ||
|
||||
name.includes('debit') ||
|
||||
name.includes('credit') ||
|
||||
name.includes('settled') ||
|
||||
name.includes('shortfall') ||
|
||||
name.includes('due')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a total under this column would mean anything.
|
||||
*
|
||||
* Not every amount is worth adding up. A "Balance After" on a transaction register is the running
|
||||
* balance *at that moment*, so its column holds several snapshots of the same account at different
|
||||
* times — summing them produces a figure that corresponds to nothing, and a reader would sooner or
|
||||
* later try to reconcile it against something.
|
||||
*
|
||||
* A plain balance on a listing report is different: there each row is one account's position at
|
||||
* one moment, and the sum is the real "total held", which is exactly what a branch wants to see.
|
||||
*/
|
||||
isTotalledColumn(column: string): boolean {
|
||||
if (!this.isAmountColumn(column)) {
|
||||
return false;
|
||||
}
|
||||
return !column.toLowerCase().includes('balance after');
|
||||
}
|
||||
|
||||
/** The figure printed under a column, as the legacy statement's "Total :" row does. */
|
||||
columnTotal(column: string): string {
|
||||
const sum = this.rows.reduce((running, row) => {
|
||||
const value = row[column];
|
||||
return running + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
return ReportViewerComponent.MONEY.format(sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to print a Total row at all.
|
||||
*
|
||||
* A single-row report is already its own total - Balance Summary is one line of figures for the
|
||||
* whole scheme, and repeating it underneath itself only invites the reader to look for a difference
|
||||
* between the two.
|
||||
*/
|
||||
get hasTotalledColumn(): boolean {
|
||||
return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column));
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
|
||||
filterForm: UntypedFormGroup = this.formBuilder.group({
|
||||
fromDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
toDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
fromDate: [''],
|
||||
toDate: [''],
|
||||
agentFrom: [1],
|
||||
agentTo: [99999],
|
||||
partyFrom: [1],
|
||||
partyTo: [99999999]
|
||||
});
|
||||
|
||||
private get filters(): { date?: string; agent?: boolean; party?: boolean } {
|
||||
return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {};
|
||||
}
|
||||
|
||||
/** True when this report's query actually reads a date range - only then is one asked for. */
|
||||
get usesDate(): boolean {
|
||||
return !!this.filters.date;
|
||||
}
|
||||
|
||||
/** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */
|
||||
get dateLabel(): string {
|
||||
return this.filters.date ?? '';
|
||||
}
|
||||
|
||||
get usesAgent(): boolean {
|
||||
return !!this.filters.agent;
|
||||
}
|
||||
|
||||
get usesParty(): boolean {
|
||||
return !!this.filters.party;
|
||||
}
|
||||
|
||||
/** True when this report takes no filters at all, so there is nothing for anyone to fill in. */
|
||||
get hasNoFilters(): boolean {
|
||||
return !this.usesDate && !this.usesAgent && !this.usesParty;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// A date is required exactly for the reports that read one, so a report without dates can be run
|
||||
// straight away instead of demanding two values it will ignore.
|
||||
if (this.usesDate) {
|
||||
this.filterForm.get('fromDate')?.setValidators([Validators.required]);
|
||||
this.filterForm.get('toDate')?.setValidators([Validators.required]);
|
||||
this.filterForm.get('fromDate')?.updateValueAndValidity();
|
||||
this.filterForm.get('toDate')?.updateValueAndValidity();
|
||||
}
|
||||
|
||||
// Nothing to narrow it by means nothing to decide, so the report simply opens showing its
|
||||
// figures. Making someone press Run on an empty form asks a question with only one answer.
|
||||
if (this.hasNoFilters) {
|
||||
this.runReport();
|
||||
}
|
||||
}
|
||||
|
||||
runReport(): void {
|
||||
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
|
||||
if (!reportKey) {
|
||||
@@ -96,20 +308,34 @@ export class ReportViewerComponent {
|
||||
}
|
||||
const raw = this.filterForm.getRawValue();
|
||||
|
||||
// Only what this report reads is sent. The backend defaults the rest to the full range, so a
|
||||
// report that ignores a filter is never handed a value that looks like it was applied.
|
||||
const params: Record<string, any> = {};
|
||||
if (this.usesDate) {
|
||||
params['fromDate'] = this.dateUtils.getDate(raw.fromDate);
|
||||
params['toDate'] = this.dateUtils.getDate(raw.toDate);
|
||||
}
|
||||
if (this.usesAgent) {
|
||||
params['agentFrom'] = raw.agentFrom;
|
||||
params['agentTo'] = raw.agentTo;
|
||||
}
|
||||
if (this.usesParty) {
|
||||
params['partyFrom'] = raw.partyFrom;
|
||||
params['partyTo'] = raw.partyTo;
|
||||
}
|
||||
|
||||
this.organizationService
|
||||
.runDdsReport(reportKey, {
|
||||
fromDate: this.dateUtils.getDate(raw.fromDate),
|
||||
toDate: this.dateUtils.getDate(raw.toDate),
|
||||
agentFrom: raw.agentFrom,
|
||||
agentTo: raw.agentTo,
|
||||
partyFrom: raw.partyFrom,
|
||||
partyTo: raw.partyTo
|
||||
})
|
||||
.runDdsReport(reportKey, params)
|
||||
.subscribe((result: any) => {
|
||||
this.available = result.available;
|
||||
this.unavailableMessage = result.message || '';
|
||||
this.columns = result.columns || [];
|
||||
this.rows = result.rows || [];
|
||||
// Captured here so the printed heading names the range these rows came from, and does not
|
||||
// silently change if the filter is edited without re-running.
|
||||
this.ranFromDate = raw.fromDate;
|
||||
this.ranToDate = raw.toDate;
|
||||
this.ranOnDate = new Date();
|
||||
this.hasRun = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../../../../../../CBS-Backend"
|
||||
},
|
||||
{
|
||||
"path": "../../../../.."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pm-container">
|
||||
<div class="pm-header">
|
||||
<button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back">
|
||||
<i class="fas fa-arrow-left"></i> Party Master
|
||||
</button>
|
||||
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
|
||||
@if (isClosed) {
|
||||
<span class="pm-closed-badge">Closed — view only</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pm-card">
|
||||
<form [formGroup]="partyForm">
|
||||
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
|
||||
<mat-tab label="Main Detail">
|
||||
<div class="pm-tab-content">
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-id-card"></i>
|
||||
<h3>Customer & Scheme</h3>
|
||||
<span class="pm-section__sub">Who this account is for, which scheme it runs under, and which agent collects for it</span>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Client ID or Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="clientId"
|
||||
[matAutocomplete]="clientAuto"
|
||||
(focus)="onClientFocus()"
|
||||
placeholder="Click to see customers, or type a name or ID…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
|
||||
@for (client of clientOptions; track client.id) {
|
||||
<mat-option [value]="client">
|
||||
<div class="pm-opt">
|
||||
<span class="pm-opt-id">{{ client.id }}</span>
|
||||
<span class="pm-opt-main">{{ client.displayName }}</span>
|
||||
<span class="pm-opt-sub">{{ client.officeName }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
<!-- Says so when the list is only the first page, so a customer missing from
|
||||
it reads as "keep typing" rather than "not a customer here". -->
|
||||
@if (clientListIsPartial) {
|
||||
<mat-option disabled class="pm-opt-more">
|
||||
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (loadedClientName) {
|
||||
<mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint>
|
||||
} @else if (clientLookupFailed) {
|
||||
<mat-hint class="pm-hint--missing">No client with this ID</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Sub Type (Scheme)<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="subType"
|
||||
[matAutocomplete]="subTypeAuto"
|
||||
(focus)="showAllSubTypes()"
|
||||
placeholder="Type to search schemes…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #subTypeAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displaySubType">
|
||||
@for (type of subTypeOptions; track type.id) {
|
||||
<mat-option [value]="type.accountTypeCode">
|
||||
<div class="pm-opt">
|
||||
<span class="pm-opt-id">{{ type.accountTypeCode }}</span>
|
||||
<span class="pm-opt-main">{{ type.accountTypeDescription }}</span>
|
||||
<span class="pm-opt-sub">{{ type.accountTypeGroup }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
@if (subTypeOptions.length === 0) {
|
||||
<mat-option disabled><em>No scheme matches that.</em></mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (subTypes.length === 0) {
|
||||
<mat-hint class="pm-hint--missing">No active account type — create one first</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Agent<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="agentId"
|
||||
[matAutocomplete]="agentAuto"
|
||||
(focus)="onAgentFocus()"
|
||||
placeholder="Click to see agents, or type a code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #agentAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayAgent">
|
||||
@for (agent of agentOptions; track agent.id) {
|
||||
<mat-option [value]="agent">
|
||||
<div class="pm-opt">
|
||||
<span class="pm-opt-id">{{ agent.agentCode }}</span>
|
||||
<span class="pm-opt-main">{{ agent.agentNameId }}</span>
|
||||
<!-- A stopped agent is still shown, marked: hiding one would silently
|
||||
drop a party's own collector from the list. -->
|
||||
<span class="pm-opt-sub">
|
||||
{{ agent.active ? agent.homeBranch : 'not collecting' }}
|
||||
</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
@if (agentOptions.length === 0) {
|
||||
<mat-option disabled><em>No agent matches that.</em></mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (loadedAgentName && !agentLookupFailed) {
|
||||
<mat-hint class="pm-hint--found">{{ loadedAgentName }}</mat-hint>
|
||||
} @else if (loadedAgentName && agentLookupFailed) {
|
||||
<mat-hint class="pm-hint--missing">{{ loadedAgentName }} is deactivated</mat-hint>
|
||||
} @else if (agentLookupFailed) {
|
||||
<mat-hint class="pm-hint--missing">No agent with this ID</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The branch this account belongs to. Asked for, not taken from the agent: an agent
|
||||
working out of one branch can hold accounts for customers of another, and an
|
||||
account that silently inherits its agent's branch is counted at the wrong one.
|
||||
-->
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Branch<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="officeId">
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (officesError) {
|
||||
<mat-hint class="pm-hint--missing">{{ officesError }}</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>The branch this account belongs to</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The customer's real deposit account. Three states, and the difference matters: an
|
||||
account is open, this party predates DDS opening them, or the party is new and the
|
||||
account is about to be opened. It used to read "Linked (id 36)", which is a number
|
||||
only the database means anything by.
|
||||
-->
|
||||
@if (savingsAccountNo) {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">DDS Account</label>
|
||||
<div class="pm-account">
|
||||
<span class="pm-account-no">{{ savingsAccountNo }}</span>
|
||||
<span class="pm-account-meta">
|
||||
{{ savingsProductName }} ·
|
||||
<span [class.pm-account-live]="savingsAccountStatus === 'Active'">{{ savingsAccountStatus }}</span>
|
||||
· {{ savingsAccountBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (partyId) {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">DDS Account</label>
|
||||
<div class="pm-account">
|
||||
<span class="pm-account-missing">No account — opened before DDS started opening them</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">DDS Account</label>
|
||||
<div class="pm-account">
|
||||
<span class="pm-account-meta">Opens automatically when this party is saved</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Party Code</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Last A/c</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="lastAccount" placeholder="—" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Nominal<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="nominal">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Party Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="partyName" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Party Name 1</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="partyName1" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Address 1<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="address1" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Address 2</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="address2" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Address 3</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="address3" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">City<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="city" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Pincode<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="pincode" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Gender</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="gender">
|
||||
<mat-option value="Male">Male</mat-option>
|
||||
<mat-option value="Female">Female</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Telephone</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Mobile No (SMS)<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="mobileNoSms" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Aadhar No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="aadharNo" mifosxRestrictInput="aadhaar" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">PAN Card No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="panCardNo" mifosxRestrictInput="pan" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">CKYC ID</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="ckycId" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Operation<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="operation">
|
||||
@for (op of operations; track op) {
|
||||
<mat-option [value]="op">{{ op }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Birth Date</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #birthPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Occupation</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="occupation">
|
||||
@for (occ of occupations; track occ) {
|
||||
<mat-option [value]="occ">{{ occ }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Risk</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="risk">
|
||||
@for (r of riskLevels; track r) {
|
||||
<mat-option [value]="r">{{ r }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-user-friends"></i>
|
||||
<h3>Minor / Guardian</h3>
|
||||
<span class="pm-section__sub">Only for a minor's account — the guardian who operates it until they come of age</span>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Adult / Minor<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="adultMinor">
|
||||
@for (opt of adultMinorOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (isMinorAccount) {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Minor Date<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required [matDatepicker]="minorPicker" formControlName="minorDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #minorPicker></mat-datepicker>
|
||||
<mat-hint>Turns 18 on</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Guardian Client ID or Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="guardianClientId"
|
||||
[matAutocomplete]="guardianAuto"
|
||||
placeholder="Type an ID or search by name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #guardianAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
|
||||
@for (client of guardianOptions; track client.id) {
|
||||
<mat-option [value]="client">
|
||||
<div class="pm-opt">
|
||||
<span class="pm-opt-id">{{ client.id }}</span>
|
||||
<span class="pm-opt-main">{{ client.displayName }}</span>
|
||||
<span class="pm-opt-sub">{{ client.officeName }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (loadedGuardianName) {
|
||||
<mat-hint class="pm-hint--found">{{ loadedGuardianName }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Min. A/c Opt</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="minAccountOpt" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Relation<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="relation" />
|
||||
<mat-hint>To the minor</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (!isMinorAccount) {
|
||||
<p class="pm-note">An adult's account needs no guardian details.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
<h3>Scheme Terms</h3>
|
||||
<span class="pm-section__sub">How much is collected daily, for how long, and what it matures to</span>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Opening Date<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<!--
|
||||
A new account opens today, so yesterday is greyed out - a date in the past would
|
||||
make the scheme look older than it is, with its lock-in further along and its
|
||||
maturity falling before the money was collected for it. No floor while editing:
|
||||
an account already on the books opened in the past by definition, and a floor of
|
||||
today would make every one of them unsaveable.
|
||||
-->
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
[matDatepicker]="openingPicker"
|
||||
[min]="minOpeningDate"
|
||||
formControlName="openingDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #openingPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Month</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" formControlName="month" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Maturity Date</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="maturityPicker" formControlName="maturityDate" />
|
||||
<mat-datepicker #maturityPicker></mat-datepicker>
|
||||
<mat-hint>Calculated</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Reopen Date</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #reopenPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Deposit Amt</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" formControlName="depositAmount" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Daily Deposit Amt<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required type="number" formControlName="dailyDepositAmount" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Days<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required type="number" formControlName="days" />
|
||||
@if (partyForm.get('days')?.hasError('exceedsTerm')) {
|
||||
<mat-error>Only {{ termDays }} days in this term</mat-error>
|
||||
} @else if (termDays) {
|
||||
<mat-hint>Term is {{ termDays }} days</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Ledger No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="ledgerNo" />
|
||||
<mat-hint>Collection book no.</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Mat. Amt</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput disabled class="pm-money" [value]="money('maturityAmount')" />
|
||||
<mat-hint>Calculated</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Interest Rate</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" formControlName="interestRate" />
|
||||
<span matTextSuffix>%</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Withdraw Type</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="withdrawType">
|
||||
@for (type of withdrawTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h3>Balances & Status</h3>
|
||||
<span class="pm-section__sub">Kept by collections and the nightly batch — shown here, never typed</span>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
|
||||
</div>
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Lien</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="lien">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
@if (lienHeld) {
|
||||
<mat-hint>Nothing can leave this account until it is released</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Why the money is held. Asked for only when it is: the flag alone says an account is
|
||||
frozen and never says what for, which is how a lien outlives the loan it secured -
|
||||
months later nobody can tell whether it has been repaid, so nobody will release it
|
||||
and nobody can justify keeping it.
|
||||
-->
|
||||
@if (lienHeld) {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Lien Reason<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="lienReason">
|
||||
@for (reason of lienReasons; track reason) {
|
||||
<mat-option [value]="reason">{{ reason }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>What this money is being held for</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Only where the list genuinely does not cover it. Picking "Other" and leaving
|
||||
this blank would say no more than leaving the whole reason blank did.
|
||||
-->
|
||||
@if (lienReasonIsOther) {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Please specify<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required maxlength="200" formControlName="lienReasonOther" placeholder="e.g. Loan #1234" />
|
||||
<mat-hint>Write the reason in your own words</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Pay Date</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #payDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Payment Amount</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput disabled class="pm-money" [value]="money('paymentAmount')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Current Bal</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput disabled class="pm-money" [value]="money('currentBalance')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Opening Bal</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput disabled class="pm-money" [value]="money('openingBalance')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
|
||||
</div>
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Int.Calc</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput disabled class="pm-money" [value]="money('interestCalc')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Interest Date</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #interestDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Sp.Description</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="spDescription" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Fr'z CD</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="freezeCode">
|
||||
@for (code of freezeCodes; track code) {
|
||||
<mat-option [value]="code">{{ code }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Adv Code</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="advCode">
|
||||
@for (code of advCodes; track code) {
|
||||
<mat-option [value]="code">{{ code }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (creditToRequired) {
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<h3>Maturity Payout Routing</h3>
|
||||
</div>
|
||||
<p class="pm-note">Transfer To Saving — say where the maturity proceeds should land.</p>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Credit To Branch<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="creditToBranch">
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.name">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (officesError) {
|
||||
<mat-hint class="pm-hint--missing">{{ officesError }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Picked from the customer's own savings accounts, never typed. The whole matured
|
||||
amount lands here, and a number that exists only as text is a payout with
|
||||
nowhere to go - which is what one party was already carrying.
|
||||
-->
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Credit To A/C<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select
|
||||
required
|
||||
formControlName="creditToAccount"
|
||||
panelWidth="min(30rem, 92vw)"
|
||||
(selectionChange)="onCreditToAccountPicked($event.value)"
|
||||
>
|
||||
@for (account of creditToAccounts; track account.id) {
|
||||
<mat-option [value]="account.accountNo">
|
||||
{{ account.accountNo }} · {{ account.productName }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (creditToAccounts.length === 0) {
|
||||
<mat-hint class="pm-hint--missing">
|
||||
{{ loadedClientName || 'This customer' }} has no other savings account to credit
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Read off the chosen account rather than asked for separately. -->
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Credit To A/C Type<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput readonly formControlName="creditToAccountType" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Nominee's Detail">
|
||||
<div class="pm-tab-content">
|
||||
<div class="pm-section">
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Nominee</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="nomineeApplicable">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (nomineesRequired) {
|
||||
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
|
||||
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
||||
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
|
||||
<i class="fas fa-plus"></i> Add Nominee
|
||||
</button>
|
||||
|
||||
@if (splitByPercentage && nominees.length > 0) {
|
||||
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
|
||||
{{ nomineeShareTotal }}% of 100% allocated@if (nomineeShareError) { — {{ nomineeShareError }} }
|
||||
</span>
|
||||
} @else if (splitBySequence && nominees.length > 0) {
|
||||
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
|
||||
@if (nomineeOrderError) {
|
||||
{{ nomineeOrderError }}
|
||||
} @else {
|
||||
Paid in the order numbered, 1 first
|
||||
}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!nomineesRequired) {
|
||||
<p class="pm-note">Set Nominee to Yes to nominate someone for this account.</p>
|
||||
}
|
||||
|
||||
@for (nominee of nominees.controls; track nominee; let i = $index) {
|
||||
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
|
||||
<div class="pm-f pm-f--date">
|
||||
<label class="pm-f-lbl">Nom.Dt<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Mobile No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f pm-f--date">
|
||||
<label class="pm-f-lbl">Birth Dt</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomBirthPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f pm-f--tight">
|
||||
<!-- Read from the date of birth, not asked: the two arriving separately is how
|
||||
a ten-year-old came to be recorded as not a minor. -->
|
||||
<label class="pm-f-lbl">Minor</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
|
||||
<mat-hint>From date of birth</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Relation<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="relation" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Gender<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="gender">
|
||||
<mat-option value="Male">Male</mat-option>
|
||||
<mat-option value="Female">Female</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">
|
||||
Guar. Name@if (isNomineeMinor(nominee)) {<b>*</b>}
|
||||
</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
|
||||
@if (isNomineeMinor(nominee)) {
|
||||
<mat-hint>Required for a minor</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (splitByPercentage) {
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Share %<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" min="0.01" max="100" step="0.01" required formControlName="sharePercent" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Order<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="pm-remove-nominee"
|
||||
(click)="removeNominee(i)"
|
||||
matTooltip="Remove nominee"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
@if (isClosed) {
|
||||
<div class="pm-callout">
|
||||
<i class="fas fa-lock"></i>
|
||||
<span>This account is closed. No further collections or edits are possible.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
A minor who has come of age. The guardian's authority to operate the account ended on that
|
||||
day, so this is something the branch has to act on rather than a detail on a tab - which is
|
||||
why it sits beside the action bar where a closed account's notice does.
|
||||
-->
|
||||
@if (minorTurnedMajor) {
|
||||
<div class="pm-callout pm-callout--action">
|
||||
<i class="fas fa-user-clock"></i>
|
||||
<span>
|
||||
This account was opened for a minor who came of age on
|
||||
<strong>{{ majorOn | dateFormat }}</strong>. The guardian can no longer
|
||||
operate it — take fresh KYC, a signature and a new mandate from the account holder.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pm-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<!--
|
||||
Closing before the term is up. Kept here beside the ordinary close rather than on the party
|
||||
list: it cannot be undone and it changes what the customer is owed, so it belongs on the
|
||||
screen where the officer has the account open in front of them, not one click from a row.
|
||||
Hidden once the account has matured - that one is paid out and closed on its own terms, and
|
||||
the server refuses this on it.
|
||||
-->
|
||||
@if (partyId && !isClosed && canCloseEarly) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="pm-early-btn"
|
||||
matTooltip="Close this account before its term ends, settling the shortfall"
|
||||
(click)="closeEarly()"
|
||||
>
|
||||
<i class="fas fa-times" style="margin-right: 6px"></i> Close Early
|
||||
</button>
|
||||
}
|
||||
@if (partyId && !isClosed) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="warn"
|
||||
matTooltip="Balance must be zero before closing"
|
||||
(click)="closeAccount()"
|
||||
>
|
||||
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="partyForm.invalid || isClosed || !!nomineeShareError || !!nomineeOrderError"
|
||||
(click)="save()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
|
||||
.pm-container {
|
||||
// Wider than the usual form page: the extra width is what keeps this many fields off a
|
||||
// second screen.
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px 20px;
|
||||
}
|
||||
|
||||
.pm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-back {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.pm-card {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.pm-tabs {
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-tab-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.pm-section {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// The subtitle sits beside the title where there is room and drops below it where there is not.
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
// Held at its natural width: letting it shrink breaks a two-word heading onto three lines.
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/*
|
||||
* Label to the left of its field, the way the screen this replaces laid them out. A stacked label
|
||||
* costs a whole row of height per field, and with 60-odd fields that is the difference between an
|
||||
* operator seeing the form at once and scrolling through three screens of it.
|
||||
*/
|
||||
.pm-f {
|
||||
display: grid;
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
column-gap: 7px;
|
||||
|
||||
&--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-f-lbl {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.25;
|
||||
text-align: right;
|
||||
color: rgb(0 0 0 / 68%);
|
||||
padding-top: 7px;
|
||||
|
||||
// Required marker, in the same red the legacy screen used.
|
||||
b {
|
||||
color: #c1121f;
|
||||
font-weight: 400;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pm-checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.pm-radio-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.pm-add-nominee {
|
||||
color: $primary;
|
||||
height: fit-content;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
|
||||
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
|
||||
* sees the state of the whole nomination without having to try to save it.
|
||||
*/
|
||||
.pm-share-total {
|
||||
align-self: center;
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.pm-share-total-bad {
|
||||
color: #b3261e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pm-empty {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* One nominee, laid out as fields that wrap rather than columns that must all fit.
|
||||
*
|
||||
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
|
||||
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
|
||||
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
|
||||
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
|
||||
*/
|
||||
.pm-nominee-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
align-items: start;
|
||||
padding: 12px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
|
||||
.pm-f {
|
||||
flex: 1 1 170px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pm-f--date {
|
||||
flex: 0 1 140px;
|
||||
}
|
||||
|
||||
.pm-f--tight {
|
||||
flex: 0 1 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-remove-nominee {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pm-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.pm-callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 24px;
|
||||
padding: 12px 16px;
|
||||
background: rgb(185 28 28 / 8%);
|
||||
border: 1px solid rgb(185 28 28 / 25%);
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (width <= 1000px) {
|
||||
.pm-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Resolved name shown under a Client / Agent / Guardian id, as the legacy screen did in red. */
|
||||
.pm-hint--found {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pm-hint--missing {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Explains why a block of fields is absent, so the screen does not just look unfinished. */
|
||||
.pm-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pm-closed-badge {
|
||||
margin-left: 12px;
|
||||
padding: 3px 11px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
background: rgb(0 0 0 / 7%);
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
/* Says whose details a block of fields is for, so the screen explains itself. */
|
||||
.pm-section__sub {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.pm-search-ic {
|
||||
color: rgb(0 0 0 / 38%);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Money reads right to left, so a column of figures only lines up when it is right-aligned; the
|
||||
* tabular figures keep every digit the same width so the decimal points stack.
|
||||
*/
|
||||
input.pm-money {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* One row of a Client / Sub Type search list: the id, the name, and where it belongs. */
|
||||
.pm-opt {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The "there are more" footer row. Quieter than a real option, since it is not one. */
|
||||
.pm-opt-more {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pm-opt-id {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--pl-accent, #b45309);
|
||||
}
|
||||
|
||||
/*
|
||||
The name is the reason the row exists, so it gets whatever width is going and is the last thing
|
||||
to be cut. Before this the branch was fixed-width and the name carried the ellipsis, so in the
|
||||
narrow panel every customer read as a single letter.
|
||||
*/
|
||||
|
||||
.pm-opt-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pm-opt-sub {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
/*
|
||||
* Dense data-entry layout.
|
||||
*
|
||||
* The screen this replaces fitted the whole form on one page - an officer opening accounts all day
|
||||
* reads it at a glance and never scrolls. Material's default field height and spacing turn the same
|
||||
* 56 fields into three screens' worth, so the whole card is tightened: shorter fields, smaller type,
|
||||
* and just enough room under each field for the hint that names the client or agent it resolved.
|
||||
*/
|
||||
/*
|
||||
* With the label moved outside, the box only has to hold the value - so it loses the height Material
|
||||
* reserves for a floating label, and the form fits a screen instead of three.
|
||||
*/
|
||||
.pm-card {
|
||||
::ng-deep {
|
||||
.mat-mdc-form-field {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
min-height: 26px;
|
||||
padding-top: 3px !important;
|
||||
padding-bottom: 3px !important;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.mat-mdc-text-field-wrapper {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.mat-mdc-input-element,
|
||||
.mat-mdc-select-value-text {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
// Room for one line of hint - the resolved client/agent name that confirms the right record.
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
height: 14px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-hint {
|
||||
font-size: 10.5px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
// Suffix icons and the datepicker toggle default to a 48px touch target, which reopens all the
|
||||
// height just saved.
|
||||
.mat-mdc-icon-button.mat-mdc-button-base {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-icon-suffix {
|
||||
padding-left: 2px;
|
||||
|
||||
.mat-datepicker-toggle svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pm-tab-content {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Blocks drawn the way the screen this replaces drew them: a hairline between groups of fields,
|
||||
* with the group's name as a quiet caption above it rather than a heading bar of its own. An
|
||||
* operator who has filled this form a hundred times navigates by field position; one filling their
|
||||
* first still gets told which block is which.
|
||||
*/
|
||||
.pm-section {
|
||||
padding: 8px 0 2px;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 11%);
|
||||
|
||||
&__header {
|
||||
margin-bottom: 6px;
|
||||
gap: 0 10px;
|
||||
|
||||
// Decoration the legacy screen did without, and the row is tighter for losing it.
|
||||
i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pm-section__sub {
|
||||
font-size: 0.72rem;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.pm-grid {
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.pm-checkbox-field,
|
||||
.pm-radio-group {
|
||||
height: 28px;
|
||||
|
||||
::ng-deep label {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is
|
||||
// no room for a label column eight fields across.
|
||||
.pm-nominee-row {
|
||||
gap: 0 10px;
|
||||
padding: 3px 0;
|
||||
|
||||
.pm-f {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 1px;
|
||||
}
|
||||
|
||||
.pm-f-lbl {
|
||||
text-align: left;
|
||||
padding-top: 0;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-note {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.pm-action-bar {
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
/*
|
||||
The customer's real deposit account. Not a form field - nothing here is typed, it is what the
|
||||
bank's books say - so it reads as a fact rather than sitting in an input box pretending to be one.
|
||||
*/
|
||||
.pm-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 3px 0;
|
||||
min-height: 26px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pm-account-no {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.pm-account-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.pm-account-live {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Said plainly: this party has no account behind it, which is a real gap, not a blank field. */
|
||||
.pm-account-missing {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
/*
|
||||
Something the branch has to do, rather than something that has gone wrong - so it reads as an
|
||||
instruction in the warning colour, not an error in red like the closed-account notice.
|
||||
*/
|
||||
.pm-callout--action {
|
||||
background: rgb(180 83 9 / 8%);
|
||||
border-color: rgb(180 83 9 / 25%);
|
||||
color: #b45309;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pl-root">
|
||||
<div class="pl-header">
|
||||
<div class="pl-header-left">
|
||||
<div class="pl-header-ic"><fa-icon icon="clipboard-check"></fa-icon></div>
|
||||
<div class="pl-header-text">
|
||||
<h2>Accounts Due for Closure</h2>
|
||||
<p>Matured DDS accounts still open — pay the customer, then close the account.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-header-right">
|
||||
<button class="pl-create-btn" type="button" [routerLink]="['/accounts/dds']">
|
||||
<fa-icon icon="arrow-left"></fa-icon>
|
||||
<span>Party Master</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!loading && parties.length > 0) {
|
||||
<div class="pl-kpis">
|
||||
<div class="pl-kpi pl-kpi--purple" matTooltip="Matured accounts still open, at either step">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="clipboard-check"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ parties.length | number }}</div>
|
||||
<div class="pl-kpi-l">Outstanding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--teal" matTooltip="Money the bank is holding on matured accounts it has not yet paid out">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalPayable | number: '1.2-2' }}</div>
|
||||
<div class="pl-kpi-l">Still to Pay</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Already paid, waiting only on the close. Separated because it is the quicker half of the
|
||||
list and needs no customer to be reached. -->
|
||||
@if (awaitingCloseCount > 0) {
|
||||
<div class="pl-kpi pl-kpi--blue" matTooltip="Paid out in full — these only need closing">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="check"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ awaitingCloseCount | number }}</div>
|
||||
<div class="pl-kpi-l">Ready to Close</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pl-card">
|
||||
@if (loading) {
|
||||
<div class="pl-empty"><p>Loading…</p></div>
|
||||
} @else if (parties.length === 0) {
|
||||
<!-- An empty list here is good news, so it says so rather than looking like a failed load. -->
|
||||
<div class="pl-empty">
|
||||
<div class="pl-empty-ic dfc-empty-ic"><fa-icon icon="check"></fa-icon></div>
|
||||
<h3>Nothing to close</h3>
|
||||
<p>No DDS account has matured and been left open. Every matured account has been paid out and closed.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pl-table-wrap">
|
||||
<table class="pl-table dfc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Party</th>
|
||||
<th>Customer</th>
|
||||
<th>Collected By</th>
|
||||
<th>Matured On</th>
|
||||
<th class="num">Payable</th>
|
||||
<th class="num">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (party of parties; track party.id) {
|
||||
<tr class="pl-row">
|
||||
<td>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ party.partyCode }}</div>
|
||||
<div class="pl-product-sub dfc-account">{{ party.savingsAccountNo || '—' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ party.partyName }}</td>
|
||||
<td>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ party.agentName || '—' }}</div>
|
||||
<div class="pl-product-sub">{{ party.agentCode }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ party.maturityDate | dateFormat }}</div>
|
||||
<!-- How long the bank has been holding it. A maturity from months back is a
|
||||
different problem from yesterday's, and the date alone does not say so. -->
|
||||
<div class="pl-product-sub" [class.dfc-stale]="daysOverdue(party) > 30">
|
||||
{{ daysOverdue(party) }} {{ daysOverdue(party) === 1 ? 'day' : 'days' }} ago
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (awaitingPayout(party)) {
|
||||
<strong>{{ party.currentBalance | number: '1.2-2' }}</strong>
|
||||
} @else {
|
||||
<!-- Paid. Shown as words rather than 0.00 so a settled account is not mistaken for
|
||||
one that was never collected on. -->
|
||||
<span class="dfc-paid">Paid out</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
<!--
|
||||
Two steps, and an account stays on this list through both: pay the customer, then
|
||||
close. The officer sees the balance fall to nil before closing, so nothing is
|
||||
closed on a payment nobody checked.
|
||||
-->
|
||||
@if (heldUnderLien(party)) {
|
||||
<!--
|
||||
Pledged against a loan. Offering Pay Out here would only be refused by the
|
||||
server, so the row says why instead — the deposit is the bank's security until
|
||||
the borrowing against it is cleared.
|
||||
-->
|
||||
<span
|
||||
class="dfc-lien"
|
||||
[matTooltip]="
|
||||
(party.amountHeldUnderLien | number: '1.2-2') +
|
||||
' is pledged against a loan. It is paid out once the loan is repaid and the lien released.'
|
||||
"
|
||||
>
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
<span>Held under lien</span>
|
||||
</span>
|
||||
} @else if (awaitingPayout(party)) {
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn pl-action-btn--edit"
|
||||
(click)="payOut(party)"
|
||||
matTooltip="Pay this money out to the customer"
|
||||
>
|
||||
<fa-icon icon="wallet"></fa-icon><span>Pay Out</span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn dfc-close-btn"
|
||||
(click)="closeAccount(party)"
|
||||
matTooltip="The money has gone out — close this account"
|
||||
>
|
||||
<fa-icon icon="check"></fa-icon><span>Close Account</span>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use 'assets/styles/product-list' as pl;
|
||||
|
||||
@include pl.product-list-styles;
|
||||
|
||||
/* An empty list here means the work is done, so the mark is the reassuring colour, not the grey one. */
|
||||
.dfc-empty-ic {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.dfc-account {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* A maturity the bank has been sitting on for over a month. Called out because the date alone reads
|
||||
* the same whether it passed yesterday or in the spring, and the two are not the same problem.
|
||||
*/
|
||||
.dfc-stale {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* An account whose money has already gone out. Words rather than 0.00, so a settled account is not
|
||||
* read as one that was never collected on - both would show the same figure.
|
||||
*/
|
||||
.dfc-paid {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* The close action. Green rather than the list's usual blue because it is the step that finishes the
|
||||
* account, and it only ever appears on a row already paid.
|
||||
*/
|
||||
.dfc-close-btn {
|
||||
color: #15803d;
|
||||
border-color: rgb(21 128 61 / 30%);
|
||||
background: rgb(21 128 61 / 6%);
|
||||
|
||||
&:hover {
|
||||
background: rgb(21 128 61 / 12%);
|
||||
border-color: rgb(21 128 61 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A deposit pledged against a loan. Not a button, because there is nothing for the clerk to do here
|
||||
* until the loan is cleared — showing one would only lead to a refusal.
|
||||
*/
|
||||
.dfc-lien {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgb(90 74 156 / 30%);
|
||||
border-radius: 4px;
|
||||
background: rgb(90 74 156 / 8%);
|
||||
color: #5a4a9c;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Plain table rather than mat-table: this list is short by design and needs no sort or paging. */
|
||||
.dfc-table {
|
||||
th,
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
background: rgb(0 0 0 / 2%);
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/** Custom Components */
|
||||
import { PayoutDialogComponent } from '../payout-dialog/payout-dialog.component';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
|
||||
/**
|
||||
* The DDS accounts whose term has run out and which are still open - the branch's list of closures
|
||||
* waiting to be done.
|
||||
*
|
||||
* A list of work, not a report. Nothing closes itself: a matured account is money the bank now owes
|
||||
* the customer, and where it goes has to be agreed with them, so each one waits here until an
|
||||
* officer deals with it. Before this existed the maturity date simply passed and nobody was told -
|
||||
* the bank held the money and the customer had to think to ask for it.
|
||||
*
|
||||
* Two steps, not one, and an account stays on this list through both:
|
||||
*
|
||||
* balance owing pay it out - to another account of theirs, or into matured-deposits payable
|
||||
* balance nil paid; close it
|
||||
*
|
||||
* Splitting them is what a branch actually does. The officer pays, sees the balance fall to nil and
|
||||
* the money land where it was meant to, and only then closes. One click doing both would close
|
||||
* accounts whose payment nobody had checked, and a closed account is far harder to put right.
|
||||
*
|
||||
* Oldest maturity first, because that is the one the bank has been holding longest.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-due-for-closure',
|
||||
templateUrl: './due-for-closure.component.html',
|
||||
styleUrls: ['./due-for-closure.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DueForClosureComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
parties: any[] = [];
|
||||
loading = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
this.loading = true;
|
||||
this.organizationService.getPartiesDueForClosure().subscribe({
|
||||
next: (parties: any) => {
|
||||
this.parties = Array.isArray(parties) ? parties : parties?.pageItems || [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.parties = [];
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** What the bank is holding in total across every matured account still open. */
|
||||
get totalPayable(): number {
|
||||
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this one is still waiting on its payment. Anything left on the account is the customer's
|
||||
* money, so it is paid before the account can be closed.
|
||||
*/
|
||||
awaitingPayout(party: any): boolean {
|
||||
return Number(party.currentBalance || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this deposit is standing as security for a loan.
|
||||
*
|
||||
* The term has ended and the money is owed, but it cannot leave while it is pledged - so the row
|
||||
* says so rather than offering a Pay Out the server would refuse. A customer who needed cash before
|
||||
* their term ended borrowed against the deposit instead of breaking it; the deposit is paid when
|
||||
* that loan is cleared.
|
||||
*/
|
||||
heldUnderLien(party: any): boolean {
|
||||
return Number(party.amountHeldUnderLien || 0) > 0;
|
||||
}
|
||||
|
||||
/** How many are already paid and now only need closing - the quicker half of the list. */
|
||||
get awaitingCloseCount(): number {
|
||||
return this.parties.filter((party) => !this.awaitingPayout(party)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the bank has been sitting on this one. Shown because a maturity from three months ago
|
||||
* is a different problem from one from yesterday, and the list alone would not say so.
|
||||
*/
|
||||
daysOverdue(party: any): number {
|
||||
const matured = this.toDate(party.maturityDate);
|
||||
if (!matured) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.floor((Date.now() - matured.getTime()) / 86400000));
|
||||
}
|
||||
|
||||
/** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */
|
||||
private toDate(value: any): Date | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
return new Date(value[0], value[1] - 1, value[2]);
|
||||
}
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
payOut(party: any): void {
|
||||
const dialogRef = this.dialog.open(PayoutDialogComponent, {
|
||||
data: party,
|
||||
width: '520px',
|
||||
autoFocus: false
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((payoutCommand: any) => {
|
||||
if (!payoutCommand) {
|
||||
return;
|
||||
}
|
||||
this.organizationService.payOutParty(party.id, payoutCommand).subscribe({
|
||||
next: () => {
|
||||
this.alertService.alert({
|
||||
type: 'Party Paid Out',
|
||||
message: payoutCommand.toSavingsAccountNo
|
||||
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo}. Close the account once you have checked it.`
|
||||
: `${party.partyCode} paid into Matured Deposits Payable, ready to transfer out. Close the account once you have checked it.`
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
// A refusal is shown by the platform's own error handler, which names the reason - repeating
|
||||
// it here would say the same thing twice.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes an account whose money has already gone out. The server refuses one still carrying a
|
||||
* balance, so this cannot close an account out from under an unpaid customer.
|
||||
*
|
||||
* Asked for first, because closing is the one step here with no way back.
|
||||
*/
|
||||
closeAccount(party: any): void {
|
||||
const confirmation = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: `Close ${party.partyCode}?`,
|
||||
dialogContext: `${party.partyName}'s account has been paid out in full and can be closed. It cannot be reopened.`,
|
||||
type: 'warn'
|
||||
}
|
||||
});
|
||||
|
||||
confirmation.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.confirm) {
|
||||
return;
|
||||
}
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
this.organizationService
|
||||
.closeParty(party.id, {
|
||||
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.alertService.alert({ type: 'Party Closed', message: `${party.partyCode} is closed.` });
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="nd-title">
|
||||
Nominees
|
||||
<span class="nd-party">{{ data.partyCode }} · {{ data.partyName }}</span>
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="nd-body">
|
||||
@if (nominees.length === 0) {
|
||||
<p class="nd-empty">Nobody is nominated on this account.</p>
|
||||
} @else {
|
||||
<p class="nd-mode">
|
||||
@if (bySequence) {
|
||||
Paid in the order listed — the first named who can be found receives the deposit.
|
||||
} @else {
|
||||
Split by share of the deposit.
|
||||
}
|
||||
</p>
|
||||
|
||||
<table class="nd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@if (bySequence) { Order } @else { # }</th>
|
||||
<th>Name</th>
|
||||
<th>Relation</th>
|
||||
<th>Nominated</th>
|
||||
@if (!bySequence) {
|
||||
<th class="nd-num">Share</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (nominee of nominees; track nominee.id; let i = $index) {
|
||||
<tr>
|
||||
<!-- The stated order where there is one; otherwise just the position in the list. -->
|
||||
<td class="nd-seq">{{ nominee.sequenceNo ?? i + 1 }}</td>
|
||||
<td>
|
||||
{{ nominee.name }}
|
||||
@if (nominee.minor) {
|
||||
<span class="nd-minor">minor · guardian {{ nominee.guardianName || '—' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ nominee.relation }}</td>
|
||||
<td>{{ nominee.nominationDate | dateFormat }}</td>
|
||||
@if (!bySequence) {
|
||||
<td class="nd-num">
|
||||
@if (nominee.sharePercent !== null && nominee.sharePercent !== undefined) {
|
||||
{{ nominee.sharePercent }}%
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (shareWarning) {
|
||||
<p class="nd-warn">{{ shareWarning }}</p>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.nd-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nd-party {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.nd-body {
|
||||
min-width: 420px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.nd-mode {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.nd-empty {
|
||||
margin: 8px 0;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.nd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
padding: 0 8px 6px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 8px 8px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shares line up to be read down the column, not across the row. */
|
||||
.nd-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.nd-seq {
|
||||
width: 32px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nd-minor {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.nd-warn {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: #b3261e;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
/** Custom Pipes */
|
||||
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
||||
|
||||
/**
|
||||
* Who this account is nominated to, read without opening it for editing.
|
||||
*
|
||||
* A clerk asked "who is on this account" has one question, not an intention to change anything, and
|
||||
* the edit form answers it only after a load, a tab and a scroll - with every field live the whole
|
||||
* time. This states it and closes.
|
||||
*
|
||||
* What is shown follows the split the account was nominated under: a share per nominee under
|
||||
* Percentage, and the order they are paid in under Sequence, because that ordering is the whole of
|
||||
* what Sequence records.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-nominee-list-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
DateFormatPipe
|
||||
],
|
||||
templateUrl: './nominee-list-dialog.component.html',
|
||||
styleUrls: ['./nominee-list-dialog.component.scss']
|
||||
})
|
||||
export class NomineeListDialogComponent {
|
||||
/** { partyCode, partyName, splitMode, nominees } */
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
|
||||
get bySequence(): boolean {
|
||||
return this.data?.splitMode === 'Sequence';
|
||||
}
|
||||
|
||||
get nominees(): any[] {
|
||||
return this.data?.nominees || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the shares come to. Shown only when it is not the whole deposit - a nomination recorded
|
||||
* before shares were captured reads as complete otherwise, and the remainder has nobody named
|
||||
* to receive it.
|
||||
*/
|
||||
get shareTotal(): number | null {
|
||||
if (this.bySequence) {
|
||||
return null;
|
||||
}
|
||||
const stated = this.nominees.filter((n) => n?.sharePercent !== null && n?.sharePercent !== undefined);
|
||||
if (stated.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return stated.reduce((sum, n) => sum + Number(n.sharePercent || 0), 0);
|
||||
}
|
||||
|
||||
get shareWarning(): string | null {
|
||||
if (this.bySequence) {
|
||||
// Under Sequence the order is the whole of the nomination, so an order nobody stated is the
|
||||
// same gap a missing share is under Percentage.
|
||||
const stated = this.nominees.filter((n) => n?.sequenceNo !== null && n?.sequenceNo !== undefined);
|
||||
return stated.length === this.nominees.length ? null : 'No order was recorded against these nominees.';
|
||||
}
|
||||
const total = this.shareTotal;
|
||||
if (total === null) {
|
||||
return 'No shares were recorded against these nominees.';
|
||||
}
|
||||
return total === 100 ? null : `The shares come to ${total}%, not 100%.`;
|
||||
}
|
||||
}
|
||||
@@ -6,538 +6,221 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pm-container">
|
||||
<div class="pm-header">
|
||||
<button mat-stroked-button [routerLink]="['../']" class="pm-back">
|
||||
<i class="fas fa-arrow-left"></i> Deposits
|
||||
</button>
|
||||
<h2 class="pm-header__title">Party Master Entry</h2>
|
||||
<div class="pl-root">
|
||||
<div class="pl-header">
|
||||
<div class="pl-header-left">
|
||||
<div class="pl-header-ic"><fa-icon icon="users"></fa-icon></div>
|
||||
<div class="pl-header-text">
|
||||
<h2>Party Master</h2>
|
||||
<p>The DDS accounts on file — each one a customer collected from daily.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-header-right">
|
||||
<div class="pl-count-chip" matTooltip="Total parties">
|
||||
<fa-icon icon="users"></fa-icon>
|
||||
<strong>{{ totalCount }}</strong>
|
||||
<span>{{ totalCount === 1 ? 'party' : 'parties' }}</span>
|
||||
</div>
|
||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>Create Party</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-card">
|
||||
<form [formGroup]="partyForm">
|
||||
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
|
||||
<mat-tab label="Main Detail">
|
||||
<div class="pm-tab-content">
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-id-card"></i>
|
||||
<h3>Identity</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Client ID</mat-label>
|
||||
<input matInput required formControlName="clientId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Sub Type</mat-label>
|
||||
<mat-select required formControlName="subType">
|
||||
@for (type of subTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Agent ID</mat-label>
|
||||
<input matInput required formControlName="agentId" />
|
||||
</mat-form-field>
|
||||
|
||||
@if (linkedSavingsAccountId) {
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Savings Account</mat-label>
|
||||
<input matInput [value]="'Linked (id ' + linkedSavingsAccountId + ')'" disabled />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Savings Account No (if any)</mat-label>
|
||||
<input matInput formControlName="savingsAccountNo" />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Party Code</mat-label>
|
||||
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Last A/c</mat-label>
|
||||
<input matInput formControlName="lastAccount" placeholder="—" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Nominal</mat-label>
|
||||
<mat-select required formControlName="nominal">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||
<mat-label>Party Name</mat-label>
|
||||
<input matInput required formControlName="partyName" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||
<mat-label>Party Name 1</mat-label>
|
||||
<input matInput formControlName="partyName1" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<h3>Address & Contact</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||
<mat-label>Address 1</mat-label>
|
||||
<input matInput required formControlName="address1" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||
<mat-label>Address 2</mat-label>
|
||||
<input matInput formControlName="address2" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||
<mat-label>Address 3</mat-label>
|
||||
<input matInput formControlName="address3" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>City</mat-label>
|
||||
<input matInput required formControlName="city" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Pincode</mat-label>
|
||||
<input matInput required formControlName="pincode" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Gender</mat-label>
|
||||
<mat-select formControlName="gender">
|
||||
<mat-option value="Male">Male</mat-option>
|
||||
<mat-option value="Female">Female</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Telephone</mat-label>
|
||||
<input matInput formControlName="telephone" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Mobile No (SMS)</mat-label>
|
||||
<input matInput required formControlName="mobileNoSms" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<h3>KYC</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Aadhar No</mat-label>
|
||||
<input matInput formControlName="aadharNo" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>PAN Card No</mat-label>
|
||||
<input matInput formControlName="panCardNo" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>CKYC ID</mat-label>
|
||||
<input matInput formControlName="ckycId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Operation</mat-label>
|
||||
<mat-select required formControlName="operation">
|
||||
@for (op of operations; track op) {
|
||||
<mat-option [value]="op">{{ op }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Birth Date</mat-label>
|
||||
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #birthPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Occupation</mat-label>
|
||||
<mat-select formControlName="occupation">
|
||||
@for (occ of occupations; track occ) {
|
||||
<mat-option [value]="occ">{{ occ }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Risk</mat-label>
|
||||
<mat-select formControlName="risk">
|
||||
@for (r of riskLevels; track r) {
|
||||
<mat-option [value]="r">{{ r }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-user-friends"></i>
|
||||
<h3>Minor / Guardian</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Adult / Minor</mat-label>
|
||||
<mat-select required formControlName="adultMinor">
|
||||
@for (opt of adultMinorOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Minor Date</mat-label>
|
||||
<input matInput [matDatepicker]="minorPicker" formControlName="minorDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #minorPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Guardian Client ID</mat-label>
|
||||
<input matInput formControlName="guardianClientId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Min. A/c Opt</mat-label>
|
||||
<input matInput formControlName="minAccountOpt" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Relation</mat-label>
|
||||
<input matInput formControlName="relation" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
<h3>Scheme Terms</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Opening Date</mat-label>
|
||||
<input matInput required [matDatepicker]="openingPicker" formControlName="openingDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #openingPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Month</mat-label>
|
||||
<input matInput type="number" formControlName="month" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Maturity Date</mat-label>
|
||||
<input matInput formControlName="maturityDate" placeholder="Calculated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Reopen Date</mat-label>
|
||||
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #reopenPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Deposit Amt</mat-label>
|
||||
<input matInput type="number" formControlName="depositAmount" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Daily Deposit Amt</mat-label>
|
||||
<input matInput required type="number" formControlName="dailyDepositAmount" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Days</mat-label>
|
||||
<input matInput required type="number" formControlName="days" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Ledger No</mat-label>
|
||||
<input matInput formControlName="ledgerNo" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Mat. Amt</mat-label>
|
||||
<input matInput formControlName="maturityAmount" placeholder="Calculated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Interest Rate</mat-label>
|
||||
<input matInput formControlName="interestRate" placeholder="From product" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Withdraw Type</mat-label>
|
||||
<mat-select formControlName="withdrawType">
|
||||
@for (type of withdrawTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h3>Status & Overdue Flags</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
|
||||
</div>
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Lien</mat-label>
|
||||
<mat-select formControlName="lien">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Pay Date</mat-label>
|
||||
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #payDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Payment Amount</mat-label>
|
||||
<input matInput type="number" formControlName="paymentAmount" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Current Bal</mat-label>
|
||||
<input matInput formControlName="currentBalance" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Opening Bal</mat-label>
|
||||
<input matInput type="number" formControlName="openingBalance" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
|
||||
</div>
|
||||
<div class="pm-checkbox-field">
|
||||
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Int.Calc</mat-label>
|
||||
<input matInput formControlName="interestCalc" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Interest Date</mat-label>
|
||||
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #interestDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
|
||||
<mat-label>Sp.Description</mat-label>
|
||||
<input matInput formControlName="spDescription" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Fr'z CD</mat-label>
|
||||
<mat-select formControlName="freezeCode">
|
||||
@for (code of freezeCodes; track code) {
|
||||
<mat-option [value]="code">{{ code }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Adv Code</mat-label>
|
||||
<mat-select formControlName="advCode">
|
||||
@for (code of advCodes; track code) {
|
||||
<mat-option [value]="code">{{ code }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (partyForm.get('advCode').value === 'Transfer To Saving') {
|
||||
<div class="pm-section">
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<h3>Maturity Payout Routing</h3>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Credit To Branch</mat-label>
|
||||
<input matInput required formControlName="creditToBranch" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Credit To A/C Type</mat-label>
|
||||
<input matInput required formControlName="creditToAccountType" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Credit To A/C</mat-label>
|
||||
<input matInput required formControlName="creditToAccount" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Nominee's Detail">
|
||||
<div class="pm-tab-content">
|
||||
<div class="pm-section">
|
||||
<div class="pm-grid">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Nominee</mat-label>
|
||||
<mat-select formControlName="nomineeApplicable">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
|
||||
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
||||
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
|
||||
<i class="fas fa-plus"></i> Add Nominee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (nominees.length === 0) {
|
||||
<p class="pm-empty">No nominees added yet.</p>
|
||||
}
|
||||
|
||||
@for (nominee of nominees.controls; track nominee; let i = $index) {
|
||||
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Nom.Dt</mat-label>
|
||||
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput required formControlName="name" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Mobile No</mat-label>
|
||||
<input matInput formControlName="mobileNo" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Birth Dt</mat-label>
|
||||
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomBirthPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Minor</mat-label>
|
||||
<mat-select required formControlName="minor">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Relation</mat-label>
|
||||
<input matInput required formControlName="relation" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Gender</mat-label>
|
||||
<mat-select required formControlName="gender">
|
||||
<mat-option value="Male">Male</mat-option>
|
||||
<mat-option value="Female">Female</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-label>Guar. Name</mat-label>
|
||||
<input matInput formControlName="guardianName" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="pm-remove-nominee"
|
||||
(click)="removeNominee(i)"
|
||||
matTooltip="Remove nominee"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
@if (isClosed) {
|
||||
<div class="pm-callout">
|
||||
<i class="fas fa-lock"></i>
|
||||
<span>This account is closed. No further collections or edits are possible.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pm-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||
@if (partyId && !isClosed) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="warn"
|
||||
matTooltip="Balance must be zero before closing"
|
||||
(click)="closeAccount()"
|
||||
>
|
||||
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
|
||||
</button>
|
||||
<!--
|
||||
Matured accounts still open. Money the bank owes a customer that nobody has been told about, so
|
||||
it is called out on the screen an officer already opens rather than left on a report they would
|
||||
have to think to run.
|
||||
-->
|
||||
@if (dueForClosureCount > 0) {
|
||||
<a class="pm-due-banner" [routerLink]="['dueforclosure']">
|
||||
<fa-icon icon="clipboard-check" class="pm-due-banner__ic"></fa-icon>
|
||||
<span class="pm-due-banner__text">
|
||||
<strong>{{ dueForClosureCount }}</strong>
|
||||
{{ dueForClosureCount === 1 ? 'account has' : 'accounts have' }} matured and
|
||||
{{ dueForClosureCount === 1 ? 'is' : 'are' }} still open —
|
||||
<!-- Nil once every one of them has been paid: what is left then is the closing, not the money. -->
|
||||
@if (dueForClosurePayable > 0) {
|
||||
<strong>{{ dueForClosurePayable | number: '1.2-2' }}</strong> still owed to customers.
|
||||
} @else {
|
||||
paid in full, waiting to be closed.
|
||||
}
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="partyForm.invalid || isClosed"
|
||||
(click)="save()"
|
||||
>
|
||||
Save
|
||||
</span>
|
||||
<span class="pm-due-banner__cta">Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon></span>
|
||||
</a>
|
||||
}
|
||||
|
||||
<div class="pl-kpis">
|
||||
<div class="pl-kpi pl-kpi--blue">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ activeCount | number }}</div>
|
||||
<div class="pl-kpi-l">Open Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--purple" matTooltip="What the agents should collect between them on a full collection day">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
|
||||
<div class="pl-kpi-l">Daily Collection Due</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--teal" matTooltip="Every open account's running balance added up">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
|
||||
<div class="pl-kpi-l">Total Deposits Held</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pl-card">
|
||||
<div class="pl-toolbar">
|
||||
<div class="pl-search">
|
||||
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
||||
<input type="text" (input)="applyFilter($any($event.target).value)" placeholder="Search by code, name or agent…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totalCount === 0) {
|
||||
<div class="pl-empty">
|
||||
<div class="pl-empty-ic"><fa-icon icon="users"></fa-icon></div>
|
||||
<h3>No parties yet</h3>
|
||||
<p>Open one so an agent can start collecting daily deposits against it.</p>
|
||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>Create Party</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="pl-table-wrap">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
|
||||
<ng-container matColumnDef="partyCode">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<div class="pl-product-cell">
|
||||
<div class="pl-product-icon"><fa-icon icon="user"></fa-icon></div>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ p.partyCode }}</div>
|
||||
<div class="pl-product-sub">{{ p.city || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="partyName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Name</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.partyName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="subType">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Sub Type</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<span class="pl-tag" [matTooltip]="p.subType">{{ p.subTypeDescription || p.subType }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="agentName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Collected By</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ p.agentName || '—' }}</div>
|
||||
<div class="pl-product-sub">{{ p.agentCode }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!--
|
||||
The customer's real deposit account. A party without one is money the bank's books do not
|
||||
know about, so it is called out rather than shown as a blank cell.
|
||||
-->
|
||||
<ng-container matColumnDef="savingsAccountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>DDS Account</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
@if (p.savingsAccountNo) {
|
||||
<div class="pl-product-meta">
|
||||
<div class="pm-list-account">{{ p.savingsAccountNo }}</div>
|
||||
<div class="pl-product-sub">{{ p.savingsAccountStatus }}</div>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="pm-list-no-account">Not opened</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="dailyDepositAmount">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Daily Deposit</th>
|
||||
<td mat-cell *matCellDef="let p" class="num">{{ p.dailyDepositAmount | number: '1.2-2' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currentBalance">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Current Balance</th>
|
||||
<td mat-cell *matCellDef="let p" class="num">
|
||||
<strong>{{ p.currentBalance | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<span class="pm-pill" [class.pm-pill--off]="p.status === 'CLOSED'">
|
||||
{{ p.status === 'CLOSED' ? 'Closed' : 'Active' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="nominees">
|
||||
<th mat-header-cell *matHeaderCellDef>Nominees</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
@if (nomineeCount(p) > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="pl-nominee-btn"
|
||||
(click)="showNominees(p)"
|
||||
matTooltip="See who this account is nominated to"
|
||||
>
|
||||
{{ nomineeCount(p) }} nominated
|
||||
</button>
|
||||
} @else {
|
||||
<span class="pl-muted">None</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
|
||||
<td mat-cell *matCellDef="let p" class="num">
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn pl-action-btn--edit"
|
||||
[routerLink]="[p.id, 'edit']"
|
||||
[matTooltip]="p.status === 'CLOSED' ? 'View this closed party' : 'Edit this party'"
|
||||
>
|
||||
<fa-icon [icon]="p.status === 'CLOSED' ? 'eye' : 'edit'"></fa-icon>
|
||||
<span>{{ p.status === 'CLOSED' ? 'View' : 'Edit' }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="pl-row"
|
||||
[class.pm-row--closed]="row.status === 'CLOSED'"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
@if (filteredCount === 0 && totalCount > 0) {
|
||||
<div class="pl-no-match">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<span>No parties match your filter.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,159 +6,128 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
@use 'assets/styles/product-list' as pl;
|
||||
|
||||
.pm-container {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
@include pl.product-list-styles;
|
||||
|
||||
/* Party-specific amber accent */
|
||||
:host {
|
||||
--pl-accent: #b45309;
|
||||
--pl-accent-l: #fef3c7;
|
||||
}
|
||||
|
||||
.pm-header {
|
||||
/* A closed party stays listed - its collections and interest are still on the books. */
|
||||
.pm-row--closed td:not(.num) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.pm-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 11px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pm-pill--off {
|
||||
background: rgb(0 0 0 / 7%);
|
||||
color: rgb(0 0 0 / 52%);
|
||||
}
|
||||
|
||||
/* The customer's real deposit account number, in the list. */
|
||||
.pm-list-account {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/*
|
||||
A party with no account behind it. Called out rather than left blank: the balance beside it is
|
||||
money the bank's own books have no record of, which is the thing worth noticing here.
|
||||
*/
|
||||
.pm-list-no-account {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
/*
|
||||
Matured accounts still open. Deliberately loud: it is money the bank owes a customer who has not
|
||||
been told, and every day it sits there is a day the bank is holding somebody else's money. Styled
|
||||
as a link to the work rather than a notice about it - reading it should be the same click as
|
||||
starting on it.
|
||||
*/
|
||||
.pm-due-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-back {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.pm-card {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.pm-tabs {
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-tab-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.pm-section {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pm-field {
|
||||
width: 100%;
|
||||
|
||||
&--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.pm-radio-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.pm-add-nominee {
|
||||
color: $primary;
|
||||
height: fit-content;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.pm-empty {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pm-nominee-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-remove-nominee {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pm-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.pm-callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 24px;
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgb(185 28 28 / 8%);
|
||||
border: 1px solid rgb(185 28 28 / 25%);
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
background: rgb(180 83 9 / 8%);
|
||||
border: 1px solid rgb(180 83 9 / 28%);
|
||||
border-radius: 8px;
|
||||
color: #92400e;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
@media (width <= 1000px) {
|
||||
.pm-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
&:hover {
|
||||
background: rgb(180 83 9 / 13%);
|
||||
}
|
||||
|
||||
.pm-nominee-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
&__ic {
|
||||
font-size: 1.05rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1 1 auto;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 700px) {
|
||||
.pm-due-banner {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The nominee count, which opens the list of names rather than the account itself.
|
||||
*
|
||||
* Styled as text that can be clicked rather than as a second action button: the row already has one
|
||||
* action, and a pair of equal-looking buttons would put "look at this" beside "change this" as
|
||||
* though the two were the same kind of thing.
|
||||
*/
|
||||
.pl-nominee-btn {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--pl-accent);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
|
||||
&:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-muted {
|
||||
color: rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
@@ -7,42 +7,40 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTable,
|
||||
MatTableDataSource,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/** Custom Components */
|
||||
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
|
||||
|
||||
/**
|
||||
* Party Master Entry component.
|
||||
* Party Master list.
|
||||
*
|
||||
* The customer's DDS account — linked to an Agent for daily collection,
|
||||
* carrying its own scheme terms, KYC, two-stage overdue flags, and a
|
||||
* nominee list. Modelled directly on the legacy Party Master Entry screen
|
||||
* this replaces.
|
||||
*
|
||||
* Backed by its own DDS Party API (`/dds/parties`). The underlying account
|
||||
* does not yet reuse the Recurring Deposit account engine (see Product
|
||||
* Setup) — that linkage is a follow-up once the RD-account-specific fields
|
||||
* this screen doesn't collect (interest/compounding periods, chart
|
||||
* selection, ...) have a home. Until then the scheme/balance fields here
|
||||
* are the Party's own.
|
||||
*
|
||||
* "Close Account" (command=close) permanently closes the Party once its
|
||||
* balance is zero - the backend rejects the command otherwise. A closed
|
||||
* Party can no longer be edited or receive new Party Tran collections.
|
||||
*
|
||||
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
|
||||
* the same client (resolved by account number, since that's what an officer has to hand) -
|
||||
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
|
||||
* leave it". There is no unlink once linked.
|
||||
* The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme
|
||||
* (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit
|
||||
* shape as Agent Master and the product lists, and shares their stylesheet.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-party-master',
|
||||
@@ -50,478 +48,132 @@ import { Dates } from 'app/core/utils/dates';
|
||||
styleUrls: ['./party-master.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
MatCheckbox,
|
||||
MatRadioModule
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatSort,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatSortHeader,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class PartyMasterComponent {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private router = inject(Router);
|
||||
export class PartyMasterComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
partyId: number | null = null;
|
||||
selectedTabIndex = 0;
|
||||
partyStatus = 'ACTIVE';
|
||||
linkedSavingsAccountId: number | null = null;
|
||||
|
||||
get isClosed(): boolean {
|
||||
return this.partyStatus === 'CLOSED';
|
||||
}
|
||||
|
||||
subTypes = [
|
||||
'Daily Deposit Scheme',
|
||||
'Masik Dhan Varsha Deposit'
|
||||
];
|
||||
operations = [
|
||||
'Self',
|
||||
'Guardian',
|
||||
'Joint'
|
||||
];
|
||||
occupations = [
|
||||
'Business',
|
||||
'Service',
|
||||
'Agriculture',
|
||||
'Homemaker',
|
||||
'Student',
|
||||
'Retired',
|
||||
'Other'
|
||||
];
|
||||
riskLevels = [
|
||||
'Low',
|
||||
'Medium',
|
||||
'High'
|
||||
];
|
||||
adultMinorOptions = [
|
||||
'Adult',
|
||||
'Minor'
|
||||
];
|
||||
withdrawTypes = [
|
||||
'Cash',
|
||||
'Transfer',
|
||||
'Clearing'
|
||||
];
|
||||
freezeCodes = [
|
||||
'Normal',
|
||||
'Debit Freeze',
|
||||
'Credit Freeze',
|
||||
'Total Freeze'
|
||||
];
|
||||
advCodes = [
|
||||
'None',
|
||||
'Transfer To Saving',
|
||||
'Renew'
|
||||
displayedColumns: string[] = [
|
||||
'partyCode',
|
||||
'partyName',
|
||||
'subType',
|
||||
'agentName',
|
||||
'savingsAccountNo',
|
||||
'dailyDepositAmount',
|
||||
'currentBalance',
|
||||
'nominees',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
partyForm: UntypedFormGroup = this.formBuilder.group({
|
||||
clientId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
subType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
savingsAccountNo: [''],
|
||||
lastAccount: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
partyCode: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
nominal: [
|
||||
'No',
|
||||
Validators.required
|
||||
],
|
||||
aadharNo: [''],
|
||||
partyName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
partyName1: [''],
|
||||
address1: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
address2: [''],
|
||||
address3: [''],
|
||||
city: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
pincode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
gender: [''],
|
||||
panCardNo: [''],
|
||||
telephone: [''],
|
||||
mobileNoSms: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
operation: [
|
||||
'Self',
|
||||
Validators.required
|
||||
],
|
||||
birthDate: [''],
|
||||
occupation: [''],
|
||||
risk: [''],
|
||||
ckycId: [''],
|
||||
adultMinor: [
|
||||
'Adult',
|
||||
Validators.required
|
||||
],
|
||||
minorDate: [''],
|
||||
guardianClientId: [''],
|
||||
minAccountOpt: [''],
|
||||
relation: [''],
|
||||
openingDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
month: [''],
|
||||
maturityDate: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
reopenDate: [''],
|
||||
depositAmount: [0],
|
||||
dailyDepositAmount: [
|
||||
0,
|
||||
Validators.required
|
||||
],
|
||||
days: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
ledgerNo: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
maturityAmount: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
interestRate: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
withdrawType: [''],
|
||||
od1Flag: [
|
||||
{
|
||||
value: false,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
od2Flag: [
|
||||
{
|
||||
value: false,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
lien: ['No'],
|
||||
payDate: [''],
|
||||
paymentAmount: [0],
|
||||
currentBalance: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
openingBalance: [0],
|
||||
isSmsActive: [true],
|
||||
isEmailActive: [false],
|
||||
interestCalc: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
interestDate: [''],
|
||||
spDescription: [''],
|
||||
freezeCode: ['Normal'],
|
||||
advCode: ['None'],
|
||||
creditToBranch: [''],
|
||||
creditToAccountType: [''],
|
||||
creditToAccount: [''],
|
||||
nomineeApplicable: ['No'],
|
||||
nomineeSplitMode: ['Percentage'],
|
||||
nominees: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
get nominees(): UntypedFormArray {
|
||||
return this.partyForm.get('nominees') as UntypedFormArray;
|
||||
}
|
||||
|
||||
private buildNomineeGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
nominationDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
name: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
mobileNo: [''],
|
||||
birthDate: [''],
|
||||
minor: [
|
||||
'No',
|
||||
Validators.required
|
||||
],
|
||||
relation: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
gender: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
guardianName: ['']
|
||||
/**
|
||||
* Who a party is nominated to, without opening it for editing.
|
||||
*
|
||||
* Asked often enough to belong on the list, and answered from what the list already holds - the
|
||||
* parties come back with their nominees on them, so this opens rather than fetches.
|
||||
*/
|
||||
showNominees(party: any): void {
|
||||
this.dialog.open(NomineeListDialogComponent, {
|
||||
data: {
|
||||
partyCode: party.partyCode,
|
||||
partyName: party.partyName,
|
||||
splitMode: party.nomineeSplitMode,
|
||||
nominees: party.nominees || []
|
||||
},
|
||||
autoFocus: false
|
||||
});
|
||||
}
|
||||
|
||||
addNominee(): void {
|
||||
this.nominees.push(this.buildNomineeGroup());
|
||||
/** How many people are named on an account, which is what the list column shows. */
|
||||
nomineeCount(party: any): number {
|
||||
return (party?.nominees || []).length;
|
||||
}
|
||||
|
||||
removeNominee(index: number): void {
|
||||
this.nominees.removeAt(index);
|
||||
/**
|
||||
* Matured accounts still open, and what they add up to. Shown here because this is the screen an
|
||||
* officer already opens - money the bank owes a customer should not wait on somebody thinking to
|
||||
* run a report.
|
||||
*/
|
||||
dueForClosureCount = 0;
|
||||
dueForClosurePayable = 0;
|
||||
|
||||
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
||||
parties: any[] = [];
|
||||
filteredCount = 0;
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
|
||||
get totalCount(): number {
|
||||
return this.parties.length;
|
||||
}
|
||||
|
||||
retrieve(): void {
|
||||
const clientId = this.partyForm.get('clientId')?.value;
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
get activeCount(): number {
|
||||
return this.parties.filter((party) => party.status !== 'CLOSED').length;
|
||||
}
|
||||
|
||||
/** What the whole book is worth today - every open account's running balance added up. */
|
||||
get totalCurrentBalance(): number {
|
||||
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
|
||||
}
|
||||
|
||||
/** What the agents should be collecting between them on a full collection day. */
|
||||
get totalDailyDue(): number {
|
||||
return this.parties
|
||||
.filter((party) => party.status !== 'CLOSED')
|
||||
.reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.organizationService.getParties().subscribe((parties: any[]) => {
|
||||
const match = (parties || []).find((party) => String(party.clientId) === String(clientId));
|
||||
if (match) {
|
||||
this.patchFormFromParty(match);
|
||||
this.parties = parties || [];
|
||||
this.dataSource = new MatTableDataSource(this.parties);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.filteredCount = this.parties.length;
|
||||
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
|
||||
});
|
||||
|
||||
this.loadDueForClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Matured accounts still open. A separate call rather than filtered out of the list above, because
|
||||
* the server decides what counts as matured - comparing dates here would drift from it the first
|
||||
* time either changed.
|
||||
*/
|
||||
private loadDueForClosure(): void {
|
||||
this.organizationService.getPartiesDueForClosure().subscribe({
|
||||
next: (due: any) => {
|
||||
const rows = Array.isArray(due) ? due : due?.pageItems || [];
|
||||
this.dueForClosureCount = rows.length;
|
||||
this.dueForClosurePayable = rows.reduce((sum: number, party: any) => sum + Number(party.currentBalance || 0), 0);
|
||||
},
|
||||
// The party list is the point of this screen; a failed banner must not take it down with it.
|
||||
error: () => {
|
||||
this.dueForClosureCount = 0;
|
||||
this.dueForClosurePayable = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.partyId = null;
|
||||
this.partyStatus = 'ACTIVE';
|
||||
this.linkedSavingsAccountId = null;
|
||||
this.partyForm.reset();
|
||||
this.partyForm.patchValue({
|
||||
nominal: 'No',
|
||||
operation: 'Self',
|
||||
adultMinor: 'Adult',
|
||||
lien: 'No',
|
||||
isSmsActive: true,
|
||||
isEmailActive: false,
|
||||
freezeCode: 'Normal',
|
||||
advCode: 'None',
|
||||
nomineeApplicable: 'No',
|
||||
nomineeSplitMode: 'Percentage',
|
||||
currentBalance: 0,
|
||||
maturityAmount: 0,
|
||||
interestRate: 0
|
||||
});
|
||||
this.nominees.clear();
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.router.navigate(['/accounts/savings']);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const raw = this.partyForm.getRawValue();
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
|
||||
|
||||
const payload: any = {
|
||||
clientId: Number(raw.clientId),
|
||||
agentId: Number(raw.agentId),
|
||||
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
|
||||
subType: raw.subType,
|
||||
lastAccount: raw.lastAccount || undefined,
|
||||
nominal: raw.nominal === 'Yes',
|
||||
aadharNo: raw.aadharNo || undefined,
|
||||
partyName: raw.partyName,
|
||||
partyName1: raw.partyName1 || undefined,
|
||||
address1: raw.address1,
|
||||
address2: raw.address2 || undefined,
|
||||
address3: raw.address3 || undefined,
|
||||
city: raw.city,
|
||||
pincode: raw.pincode,
|
||||
gender: raw.gender || undefined,
|
||||
panCardNo: raw.panCardNo || undefined,
|
||||
telephone: raw.telephone || undefined,
|
||||
mobileNoSms: raw.mobileNoSms,
|
||||
operation: raw.operation,
|
||||
birthDate: asDate(raw.birthDate),
|
||||
occupation: raw.occupation || undefined,
|
||||
risk: raw.risk || undefined,
|
||||
ckycId: raw.ckycId || undefined,
|
||||
adultMinor: raw.adultMinor,
|
||||
minorDate: asDate(raw.minorDate),
|
||||
guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined,
|
||||
minAccountOpt: raw.minAccountOpt || undefined,
|
||||
relation: raw.relation || undefined,
|
||||
openingDate: asDate(raw.openingDate),
|
||||
month: raw.month || undefined,
|
||||
reopenDate: asDate(raw.reopenDate),
|
||||
depositAmount: raw.depositAmount || undefined,
|
||||
dailyDepositAmount: raw.dailyDepositAmount,
|
||||
days: raw.days,
|
||||
withdrawType: raw.withdrawType || undefined,
|
||||
lien: raw.lien === 'Yes',
|
||||
payDate: asDate(raw.payDate),
|
||||
paymentAmount: raw.paymentAmount || undefined,
|
||||
openingBalance: raw.openingBalance || 0,
|
||||
isSmsActive: !!raw.isSmsActive,
|
||||
isEmailActive: !!raw.isEmailActive,
|
||||
interestDate: asDate(raw.interestDate),
|
||||
spDescription: raw.spDescription || undefined,
|
||||
freezeCode: raw.freezeCode || undefined,
|
||||
advCode: raw.advCode || undefined,
|
||||
creditToBranch: raw.creditToBranch || undefined,
|
||||
creditToAccountType: raw.creditToAccountType || undefined,
|
||||
creditToAccount: raw.creditToAccount || undefined,
|
||||
nomineeApplicable: raw.nomineeApplicable === 'Yes',
|
||||
nomineeSplitMode: raw.nomineeSplitMode || undefined,
|
||||
dateFormat,
|
||||
locale,
|
||||
nominees: (raw.nominees || []).map((nominee: any) => ({
|
||||
nominationDate: this.dateUtils.getDate(nominee.nominationDate),
|
||||
name: nominee.name,
|
||||
mobileNo: nominee.mobileNo || undefined,
|
||||
birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined,
|
||||
minor: nominee.minor === 'Yes',
|
||||
relation: nominee.relation,
|
||||
gender: nominee.gender,
|
||||
guardianName: nominee.guardianName || undefined
|
||||
}))
|
||||
};
|
||||
|
||||
if (this.partyId) {
|
||||
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
|
||||
} else {
|
||||
this.organizationService.createParty(payload).subscribe(() => this.exit());
|
||||
}
|
||||
}
|
||||
|
||||
closeAccount(): void {
|
||||
if (!this.partyId) {
|
||||
return;
|
||||
}
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
this.organizationService
|
||||
.closeParty(this.partyId, {
|
||||
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
.subscribe(() => this.exit());
|
||||
}
|
||||
|
||||
private patchFormFromParty(party: any): void {
|
||||
this.partyId = party.id;
|
||||
this.partyStatus = party.status || 'ACTIVE';
|
||||
this.linkedSavingsAccountId = party.savingsAccountId || null;
|
||||
this.partyForm.patchValue({
|
||||
clientId: party.clientId,
|
||||
subType: party.subType,
|
||||
agentId: party.agentId,
|
||||
partyCode: party.partyCode,
|
||||
lastAccount: party.lastAccount,
|
||||
nominal: party.nominal ? 'Yes' : 'No',
|
||||
aadharNo: party.aadharNo,
|
||||
partyName: party.partyName,
|
||||
partyName1: party.partyName1,
|
||||
address1: party.address1,
|
||||
address2: party.address2,
|
||||
address3: party.address3,
|
||||
city: party.city,
|
||||
pincode: party.pincode,
|
||||
gender: party.gender,
|
||||
panCardNo: party.panCardNo,
|
||||
telephone: party.telephone,
|
||||
mobileNoSms: party.mobileNoSms,
|
||||
operation: party.operation,
|
||||
birthDate: party.birthDate ? new Date(party.birthDate) : null,
|
||||
occupation: party.occupation,
|
||||
risk: party.risk,
|
||||
ckycId: party.ckycId,
|
||||
adultMinor: party.adultMinor,
|
||||
minorDate: party.minorDate ? new Date(party.minorDate) : null,
|
||||
guardianClientId: party.guardianClientId,
|
||||
minAccountOpt: party.minAccountOpt,
|
||||
relation: party.relation,
|
||||
openingDate: party.openingDate ? new Date(party.openingDate) : null,
|
||||
month: party.month,
|
||||
maturityDate: party.maturityDate,
|
||||
reopenDate: party.reopenDate ? new Date(party.reopenDate) : null,
|
||||
depositAmount: party.depositAmount,
|
||||
dailyDepositAmount: party.dailyDepositAmount,
|
||||
days: party.days,
|
||||
ledgerNo: party.ledgerNo,
|
||||
maturityAmount: party.maturityAmount,
|
||||
interestRate: party.interestRate,
|
||||
withdrawType: party.withdrawType,
|
||||
od1Flag: party.od1Flag,
|
||||
od2Flag: party.od2Flag,
|
||||
lien: party.lien ? 'Yes' : 'No',
|
||||
payDate: party.payDate ? new Date(party.payDate) : null,
|
||||
paymentAmount: party.paymentAmount,
|
||||
currentBalance: party.currentBalance,
|
||||
openingBalance: party.openingBalance,
|
||||
isSmsActive: party.isSmsActive,
|
||||
isEmailActive: party.isEmailActive,
|
||||
interestCalc: party.interestCalc,
|
||||
interestDate: party.interestDate ? new Date(party.interestDate) : null,
|
||||
spDescription: party.spDescription,
|
||||
freezeCode: party.freezeCode,
|
||||
advCode: party.advCode,
|
||||
creditToBranch: party.creditToBranch,
|
||||
creditToAccountType: party.creditToAccountType,
|
||||
creditToAccount: party.creditToAccount,
|
||||
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
|
||||
nomineeSplitMode: party.nomineeSplitMode
|
||||
});
|
||||
|
||||
this.nominees.clear();
|
||||
(party.nominees || []).forEach((nominee: any) => {
|
||||
const group = this.buildNomineeGroup();
|
||||
group.patchValue({
|
||||
nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null,
|
||||
name: nominee.name,
|
||||
mobileNo: nominee.mobileNo,
|
||||
birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null,
|
||||
minor: nominee.minor ? 'Yes' : 'No',
|
||||
relation: nominee.relation,
|
||||
gender: nominee.gender,
|
||||
guardianName: nominee.guardianName
|
||||
});
|
||||
this.nominees.push(group);
|
||||
});
|
||||
applyFilter(filterValue: string): void {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Pay out {{ data.partyCode }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<!-- What is being paid and to whom, stated once at the top so the officer confirms against the
|
||||
customer in front of them rather than against a row they clicked a moment ago. -->
|
||||
<div class="po-summary">
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Customer</span>
|
||||
<span class="po-summary__value">{{ data.partyName }}</span>
|
||||
</div>
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Matured on</span>
|
||||
<span class="po-summary__value">{{ data.maturityDate | dateFormat }}</span>
|
||||
</div>
|
||||
<div class="po-summary__row po-summary__row--amount">
|
||||
<span class="po-summary__label">Amount payable</span>
|
||||
<span class="po-summary__value">{{ data.currentBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
} @else {
|
||||
<p class="po-question">Where should this go?</p>
|
||||
|
||||
<mat-radio-group [(ngModel)]="destination" class="po-choices">
|
||||
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0" class="po-choice">
|
||||
<span class="po-choice__title">Into another account of theirs here</span>
|
||||
@if (ownAccounts.length === 0) {
|
||||
<span class="po-choice__note">{{ data.partyName }} has no other savings account at this bank</span>
|
||||
} @else {
|
||||
<span class="po-choice__note">Moved across today</span>
|
||||
}
|
||||
</mat-radio-button>
|
||||
|
||||
@if (destination === 'own' && ownAccounts.length > 0) {
|
||||
<mat-form-field appearance="outline" class="po-account">
|
||||
<mat-label>Account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountNo" panelWidth="min(28rem, 90vw)">
|
||||
@for (account of ownAccounts; track account.id) {
|
||||
<mat-option [value]="account.accountNo">
|
||||
{{ account.accountNo }} · {{ account.productName }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-radio-button value="other" class="po-choice">
|
||||
<span class="po-choice__title">To an account at another bank</span>
|
||||
<span class="po-choice__note">
|
||||
Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers
|
||||
</span>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
@if (destination === 'other') {
|
||||
<!-- Said plainly, because the officer will be asked by the customer when the money arrives. -->
|
||||
<div class="po-callout">
|
||||
The money comes off {{ data.partyCode }} today and sits in the bank's Matured Deposits Payable
|
||||
account until the transfer is made from Fund Transfers — so it is accounted for the whole
|
||||
time, and a failed transfer loses nothing.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close>Cancel</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/* What is being paid and to whom. Read against the customer standing there, so it comes first. */
|
||||
.po-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
background: rgb(0 0 0 / 3%);
|
||||
border-radius: 6px;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
/* The figure the officer is about to hand over - given the weight that deserves. */
|
||||
&--amount {
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.po-question {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.po-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Each choice carries its own consequence underneath it. Two radio labels alone would not tell an
|
||||
* officer that one keeps the money inside the bank and the other starts it on its way out.
|
||||
*/
|
||||
.po-choice {
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
&__note {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.po-account {
|
||||
width: 100%;
|
||||
margin: 8px 0 4px 32px;
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
/* What will actually happen, in the words the customer will ask about. */
|
||||
.po-callout {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgb(180 83 9 / 8%);
|
||||
border: 1px solid rgb(180 83 9 / 22%);
|
||||
border-radius: 6px;
|
||||
color: #92400e;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
|
||||
/** Custom Pipes */
|
||||
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
||||
|
||||
/**
|
||||
* Where a matured DDS account's money should go.
|
||||
*
|
||||
* Two destinations, and the difference is whether the money leaves the bank. Another account of the
|
||||
* customer's here is a move across the books. Anywhere else means a transfer out, which clears later
|
||||
* or fails - so the money is parked in the matured-deposits payable account first and the branch
|
||||
* sends it on from there, rather than being launched into a gap where it has left one account and
|
||||
* arrived at none.
|
||||
*
|
||||
* The amount is not asked for. It is whatever the account holds, worked out by the server: a figure
|
||||
* typed here could pay out more than the customer has, or quietly leave some behind.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-payout-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressBarModule,
|
||||
DateFormatPipe
|
||||
],
|
||||
templateUrl: './payout-dialog.component.html',
|
||||
styleUrls: ['./payout-dialog.component.scss']
|
||||
})
|
||||
export class PayoutDialogComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private dialogRef = inject(MatDialogRef<PayoutDialogComponent>);
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
|
||||
/** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */
|
||||
destination: 'own' | 'other' = 'own';
|
||||
|
||||
/**
|
||||
* The customer's accounts this maturity can be paid into.
|
||||
*
|
||||
* Worked out by the server, which applies the same conditions the payout enforces: an active
|
||||
* savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme
|
||||
* accounts. Filtering here instead let the two drift - the list once offered a scheme account
|
||||
* belonging to a party that had already closed, which would have stranded the money.
|
||||
*/
|
||||
ownAccounts: any[] = [];
|
||||
selectedAccountNo = '';
|
||||
loading = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
|
||||
next: (accounts: any) => {
|
||||
this.ownAccounts = accounts || [];
|
||||
|
||||
// Nothing here to pay into, so the only real choice is a transfer out - offered as the
|
||||
// default rather than leaving the officer on an option that cannot be completed.
|
||||
if (this.ownAccounts.length === 0) {
|
||||
this.destination = 'other';
|
||||
} else if (this.ownAccounts.length === 1) {
|
||||
this.selectedAccountNo = this.ownAccounts[0].accountNo;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.ownAccounts = [];
|
||||
this.destination = 'other';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get canPayOut(): boolean {
|
||||
if (this.loading) {
|
||||
return false;
|
||||
}
|
||||
return this.destination === 'other' || !!this.selectedAccountNo;
|
||||
}
|
||||
|
||||
payOut(): void {
|
||||
this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {});
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Close {{ data.partyCode }} early</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
|
||||
<!--
|
||||
The account as it stands, so the clerk is choosing against real figures rather than from memory.
|
||||
The term still to run is the whole reason there is a charge at all.
|
||||
-->
|
||||
<div class="pcd-summary">
|
||||
<div class="pcd-row">
|
||||
<span>Customer</span>
|
||||
<strong>{{ data.partyName }}</strong>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span>Balance</span>
|
||||
<strong class="pcd-amount">{{ data.currentBalance | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span>Matures on</span>
|
||||
<strong>{{ data.maturityDate | dateFormat }}</strong>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span>Scheme rate</span>
|
||||
<strong>{{ data.interestRate | number: '1.0-2' }}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="pcd-question">Who bears the cost of closing early?</h4>
|
||||
|
||||
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
|
||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
|
||||
<mat-radio-button value="CUSTOMER"></mat-radio-button>
|
||||
<div class="pcd-choice-body">
|
||||
<div class="pcd-choice-title">Customer</div>
|
||||
@if (scheme) {
|
||||
<div class="pcd-choice-detail">
|
||||
Interest recalculated at
|
||||
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
|
||||
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken
|
||||
back before the balance is paid out.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
|
||||
<mat-radio-button value="AGENT"></mat-radio-button>
|
||||
<div class="pcd-choice-body">
|
||||
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
|
||||
<div class="pcd-choice-detail">
|
||||
The customer keeps a full month's interest, and the agent funds the days left in this month. The
|
||||
balance is paid out in full.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</mat-radio-group>
|
||||
|
||||
<!--
|
||||
Stated rather than offered as a third option: the agent is charged whichever answer is picked, and
|
||||
a clerk who did not know that would be surprised by it afterwards.
|
||||
-->
|
||||
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
|
||||
<p class="pcd-note">
|
||||
Either way, {{ data.agentCode }} is charged
|
||||
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
|
||||
of the commission owed to them — the round was not kept going.
|
||||
</p>
|
||||
}
|
||||
|
||||
<h4 class="pcd-question">Where should the balance go?</h4>
|
||||
|
||||
<mat-radio-group class="pcd-destinations" [(ngModel)]="destination">
|
||||
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
|
||||
Into another account of theirs at this bank
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="other">Park it for transfer to another bank</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
@if (destination === 'own' && ownAccounts.length > 0) {
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Credit to account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountNo">
|
||||
@for (account of ownAccounts; track account.accountNo) {
|
||||
<mat-option [value]="account.accountNo"> {{ account.accountNo }} — {{ account.productName }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (ownAccounts.length === 0 && !loading) {
|
||||
<p class="pcd-note">
|
||||
This customer has no other account here, so the balance is parked in matured-deposits payable for the
|
||||
branch to send on.
|
||||
</p>
|
||||
}
|
||||
|
||||
<p class="pcd-warn">This closes the account. It cannot be reopened.</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
|
||||
Close Account
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/* The account as it stands, so the choice below is made against figures rather than from memory. */
|
||||
.pcd-summary {
|
||||
margin: 4px 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgb(0 0 0 / 3%);
|
||||
}
|
||||
|
||||
.pcd-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
font-size: 0.85rem;
|
||||
|
||||
span {
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.pcd-amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pcd-question {
|
||||
margin: 20px 0 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pcd-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Each answer is a card rather than a bare radio, because the two are not interchangeable - they take
|
||||
* money from different people, and the consequence has to be readable before the choice is made.
|
||||
*/
|
||||
.pcd-choice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px 12px 12px;
|
||||
border: 1px solid rgb(0 0 0 / 12%);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 2%);
|
||||
}
|
||||
}
|
||||
|
||||
.pcd-choice--on {
|
||||
border-color: #b45309;
|
||||
background: rgb(180 83 9 / 5%);
|
||||
}
|
||||
|
||||
.pcd-choice-body {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.pcd-choice-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.pcd-choice-detail {
|
||||
margin-top: 3px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
}
|
||||
|
||||
.pcd-destinations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pcd-field {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pcd-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
}
|
||||
|
||||
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
|
||||
.pcd-warn {
|
||||
margin: 18px 0 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgb(185 28 28 / 25%);
|
||||
background: rgb(185 28 28 / 6%);
|
||||
color: #b91c1c;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
|
||||
/** Custom Pipes */
|
||||
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
||||
|
||||
/**
|
||||
* Closing a DDS account before its term is up.
|
||||
*
|
||||
* A customer who stops saving early costs the bank the interest it had promised on money it no longer
|
||||
* has for as long as it expected. Somebody has to carry that, and the bank allows two answers - so
|
||||
* this asks which, and it is the only thing it asks.
|
||||
*
|
||||
* What each answer costs is the scheme's, not the clerk's: the reduced rate and the agent's share are
|
||||
* set once when the scheme is created and shown here as fact. A figure typed at the counter is a
|
||||
* figure that can be typed differently for the next customer, which is exactly what a published term
|
||||
* is meant to prevent.
|
||||
*
|
||||
* The destination works as it does on a maturity payout - another account of theirs here, or parked
|
||||
* in matured-deposits payable for a transfer out - because what is left over is still their money and
|
||||
* has to reach them the same way.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-premature-close-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressBarModule,
|
||||
DateFormatPipe
|
||||
],
|
||||
templateUrl: './premature-close-dialog.component.html',
|
||||
styleUrls: ['./premature-close-dialog.component.scss']
|
||||
})
|
||||
export class PrematureCloseDialogComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private productsService = inject(ProductsService);
|
||||
private dialogRef = inject(MatDialogRef<PrematureCloseDialogComponent>);
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
|
||||
/** Who carries the shortfall. No default - the two answers take money from different people. */
|
||||
borneBy: 'CUSTOMER' | 'AGENT' | '' = '';
|
||||
|
||||
/** 'own' moves what is left to another account here; 'other' parks it for a transfer out. */
|
||||
destination: 'own' | 'other' = 'own';
|
||||
|
||||
ownAccounts: any[] = [];
|
||||
selectedAccountNo = '';
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* The scheme's terms, shown so the clerk can tell the customer what each answer costs before
|
||||
* choosing. Null while loading, and left null if the scheme cannot be read - the choice is then
|
||||
* still offered, because the server applies the terms either way and a screen that could not
|
||||
* display them should not block a closure the customer has asked for.
|
||||
*/
|
||||
scheme: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
|
||||
next: (accounts: any) => {
|
||||
this.ownAccounts = accounts || [];
|
||||
if (this.ownAccounts.length === 0) {
|
||||
this.destination = 'other';
|
||||
} else if (this.ownAccounts.length === 1) {
|
||||
this.selectedAccountNo = this.ownAccounts[0].accountNo;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.ownAccounts = [];
|
||||
this.destination = 'other';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// The terms are read from the scheme the account runs on, so what is shown here is what the
|
||||
// server will actually charge rather than a second copy that can drift from it. Matched on the
|
||||
// code the party carries, which is the only handle it has on its scheme.
|
||||
this.productsService.getDdsAccountTypes().subscribe({
|
||||
next: (schemes: any) => {
|
||||
const all = Array.isArray(schemes) ? schemes : schemes?.pageItems || [];
|
||||
this.scheme = all.find((s: any) => s.accountTypeCode === this.data.subType) || null;
|
||||
},
|
||||
error: () => (this.scheme = null)
|
||||
});
|
||||
}
|
||||
|
||||
/** The rate the customer would be left on if they carry it. Never below nothing. */
|
||||
get reducedRate(): number {
|
||||
const rate = Number(this.data.interestRate || 0);
|
||||
const reduction = Number(this.scheme?.prematureInterestReduction || 0);
|
||||
return Math.max(0, rate - reduction);
|
||||
}
|
||||
|
||||
get canClose(): boolean {
|
||||
if (this.loading || !this.borneBy) {
|
||||
return false;
|
||||
}
|
||||
return this.destination === 'other' || !!this.selectedAccountNo;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close({
|
||||
shortfallBorneBy: this.borneBy,
|
||||
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { HasPermissionDirective } from './has-permission/has-permission.directive';
|
||||
import { FormatAmountDirective } from './format-amount.directive';
|
||||
import { ValidateOnFocusDirective } from './validate-on-focus.directive';
|
||||
import { RestrictInputDirective } from './restrict-input.directive';
|
||||
|
||||
/**
|
||||
* Directives Module
|
||||
@@ -25,12 +26,14 @@ import { ValidateOnFocusDirective } from './validate-on-focus.directive';
|
||||
CommonModule,
|
||||
HasPermissionDirective,
|
||||
FormatAmountDirective,
|
||||
ValidateOnFocusDirective
|
||||
ValidateOnFocusDirective,
|
||||
RestrictInputDirective
|
||||
],
|
||||
exports: [
|
||||
HasPermissionDirective,
|
||||
FormatAmountDirective,
|
||||
ValidateOnFocusDirective
|
||||
ValidateOnFocusDirective,
|
||||
RestrictInputDirective
|
||||
]
|
||||
})
|
||||
export class DirectivesModule {}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Directive, HostListener, Input, ElementRef, inject } from '@angular/core';
|
||||
import { NgControl } from '@angular/forms';
|
||||
|
||||
/** The kinds of field this knows how to hold to a shape. */
|
||||
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar';
|
||||
|
||||
interface Rule {
|
||||
/** Characters this kind will accept at all. */
|
||||
allowed: RegExp;
|
||||
/** How many of them the field can hold, so it cannot be overfilled. */
|
||||
maxLength: number;
|
||||
/** Whether what is typed is forced to upper case as it is entered. */
|
||||
upper?: boolean;
|
||||
}
|
||||
|
||||
const RULES: Record<RestrictKind, Rule> = {
|
||||
digits: { allowed: /[0-9]/, maxLength: 20 },
|
||||
phone: { allowed: /[0-9+()\-\s]/, maxLength: 20 },
|
||||
// A PAN is five letters, four digits, one letter - and is always written in capitals.
|
||||
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
|
||||
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
|
||||
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops a field being filled with something it could never hold.
|
||||
*
|
||||
* A mobile number typed as letters, or a PAN of fifteen characters, is not a mistake worth
|
||||
* discovering on save - the customer is at the counter now, and the clerk is looking at the field
|
||||
* they just typed. Refusing the keystroke says so at the moment it happens, with nothing to read
|
||||
* and nothing to undo.
|
||||
*
|
||||
* Pasting is handled too, and is where this earns its place: a number copied from a spreadsheet
|
||||
* arrives with spaces, dashes or a +91 in front, and pasting is the one way into a field that
|
||||
* bypasses every keystroke rule. What can be salvaged is kept, the rest dropped.
|
||||
*
|
||||
* This only refuses characters the field cannot contain. Whether what is left is a valid number is
|
||||
* a separate question, answered by the form's own validators and again by the server - a screen
|
||||
* restriction is a convenience, not a guarantee.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[mifosxRestrictInput]',
|
||||
standalone: true
|
||||
})
|
||||
export class RestrictInputDirective {
|
||||
private el = inject(ElementRef<HTMLInputElement>);
|
||||
private control = inject(NgControl, { optional: true });
|
||||
|
||||
@Input('mifosxRestrictInput') kind: RestrictKind = 'digits';
|
||||
|
||||
private get rule(): Rule {
|
||||
return RULES[this.kind] ?? RULES.digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that move around or edit rather than enter a character, plus the usual copy/paste and
|
||||
* select-all chords. Blocking these would leave the clerk unable to correct their own typing.
|
||||
*/
|
||||
private isEditingKey(event: KeyboardEvent): boolean {
|
||||
const navigation = [
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Tab',
|
||||
'Escape',
|
||||
'Enter',
|
||||
'Home',
|
||||
'End',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown'
|
||||
];
|
||||
return navigation.includes(event.key) || event.ctrlKey || event.metaKey;
|
||||
}
|
||||
|
||||
@HostListener('keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (this.isEditingKey(event) || event.key.length > 1) {
|
||||
return;
|
||||
}
|
||||
const input = this.el.nativeElement as HTMLInputElement;
|
||||
const hasSelection = input.selectionStart !== input.selectionEnd;
|
||||
|
||||
if (!this.rule.allowed.test(event.key)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Replacing a selection does not make the field any longer, so only a plain insert can overfill.
|
||||
if (!hasSelection && input.value.length >= this.rule.maxLength) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('paste', ['$event'])
|
||||
onPaste(event: ClipboardEvent): void {
|
||||
event.preventDefault();
|
||||
const pasted = event.clipboardData?.getData('text') ?? '';
|
||||
const kept = Array.from(pasted)
|
||||
.filter((ch) => this.rule.allowed.test(ch))
|
||||
.join('')
|
||||
.slice(0, this.rule.maxLength);
|
||||
this.write(this.rule.upper ? kept.toUpperCase() : kept);
|
||||
}
|
||||
|
||||
/** Upper case is applied as the field is left, so the caret does not jump while typing. */
|
||||
@HostListener('blur')
|
||||
onBlur(): void {
|
||||
if (!this.rule.upper) {
|
||||
return;
|
||||
}
|
||||
const current = String(this.el.nativeElement.value ?? '');
|
||||
if (current !== current.toUpperCase()) {
|
||||
this.write(current.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes through the form control where there is one, so the value the form holds and the value
|
||||
* on screen stay the same thing - setting only the element would leave the form with the old one.
|
||||
*/
|
||||
private write(value: string): void {
|
||||
this.el.nativeElement.value = value;
|
||||
if (this.control?.control) {
|
||||
this.control.control.setValue(value);
|
||||
} else {
|
||||
this.el.nativeElement.dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,350 +6,175 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="am-container">
|
||||
<div class="am-header">
|
||||
<button mat-stroked-button [routerLink]="['../']" class="am-back">
|
||||
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
|
||||
</button>
|
||||
<h2 class="am-header__title">Agent Master Entry</h2>
|
||||
<div class="pl-root">
|
||||
<div class="pl-header">
|
||||
<div class="pl-header-left">
|
||||
<div class="pl-header-ic"><fa-icon icon="user-tie"></fa-icon></div>
|
||||
<div class="pl-header-text">
|
||||
<h2>Agent Master</h2>
|
||||
<p>The DDS collection agents on file.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-header-right">
|
||||
<div class="pl-count-chip" matTooltip="Total agents">
|
||||
<fa-icon icon="user-tie"></fa-icon>
|
||||
<strong>{{ totalCount }}</strong>
|
||||
<span>{{ totalCount === 1 ? 'agent' : 'agents' }}</span>
|
||||
</div>
|
||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>Create Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-card">
|
||||
<form [formGroup]="agentForm">
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-id-card"></i>
|
||||
<h3>Agent Details</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID</mat-label>
|
||||
<input matInput required formControlName="clientId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Code</mat-label>
|
||||
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Name ID</mat-label>
|
||||
<input matInput required formControlName="agentNameId" placeholder="Person or institution name" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 1)</mat-label>
|
||||
<input matInput required formControlName="agentAddress1" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 2)</mat-label>
|
||||
<input matInput formControlName="agentAddress2" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent City</mat-label>
|
||||
<input matInput required formControlName="agentCity" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent PinCode</mat-label>
|
||||
<input matInput required formControlName="agentPinCode" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Telephone</mat-label>
|
||||
<input matInput formControlName="agentTelephone" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Home Branch</mat-label>
|
||||
<input matInput required formControlName="homeBranch" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pl-kpis">
|
||||
<div class="pl-kpi pl-kpi--blue">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="user-tie"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalCount | number }}</div>
|
||||
<div class="pl-kpi-l">Total Agents</div>
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-percent"></i>
|
||||
<h3>Collection & Commission Settings</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Com. Calculation</mat-label>
|
||||
<mat-select required formControlName="commissionCalculation">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>TDS</mat-label>
|
||||
<mat-select required formControlName="tdsApplicable">
|
||||
<mat-option value="Yes">Yes</mat-option>
|
||||
<mat-option value="No">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>TDS %</mat-label>
|
||||
<input matInput type="number" formControlName="tdsPercent" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Cur.TDS</mat-label>
|
||||
<input matInput formControlName="currentTds" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>TDS Amt</mat-label>
|
||||
<input matInput formControlName="tdsAmount" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Limit</mat-label>
|
||||
<input matInput type="number" formControlName="limit" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Machine Rent</mat-label>
|
||||
<input matInput type="number" formControlName="machineRent" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--purple">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="toggle-on"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ activeCount | number }}</div>
|
||||
<div class="pl-kpi-l">Active Agents</div>
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-piggy-bank"></i>
|
||||
<h3>Settlement & Security Accounts</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Saving Branch</mat-label>
|
||||
<input matInput required formControlName="savingBranch" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C Type</mat-label>
|
||||
<mat-select required formControlName="agentSavingAccountType">
|
||||
@for (accountType of agentSavingAccountTypes; track accountType) {
|
||||
<mat-option [value]="accountType">{{ accountType }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C No</mat-label>
|
||||
<input matInput required formControlName="agentSavingAccountNo" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Trf Branch</mat-label>
|
||||
<input matInput formControlName="transferBranch" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Trf.Amt A/C Type</mat-label>
|
||||
<input matInput formControlName="transferAmountAccountType" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Trf.Amt A/C No</mat-label>
|
||||
<input matInput formControlName="transferAccountNo" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Security Branch</mat-label>
|
||||
<input matInput formControlName="agentSecurityBranch" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Security A/C Type</mat-label>
|
||||
<mat-select formControlName="agentSecurityAccountType">
|
||||
@for (accountType of agentSavingAccountTypes; track accountType) {
|
||||
<mat-option [value]="accountType">{{ accountType }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Security A/C No</mat-label>
|
||||
<input matInput formControlName="agentSecurityAccountNo" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Date</mat-label>
|
||||
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #openingDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Birth Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="birthDatePicker"
|
||||
formControlName="agentBirthDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #birthDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Current Balance</mat-label>
|
||||
<input matInput formControlName="agentCurrentBalance" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Balance</mat-label>
|
||||
<input matInput formControlName="agentOpeningBalance" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--teal" matTooltip="Money collected from parties that agents have not yet handed over to a branch">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
|
||||
<div class="pl-kpi-l">Cash With Agents (Unsettled)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-clock"></i>
|
||||
<h3>Collection Limit History</h3>
|
||||
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addLimit()">
|
||||
<i class="fas fa-plus"></i> Add Limit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (limits.length === 0) {
|
||||
<p class="am-empty-guarantors">No limit periods added yet.</p>
|
||||
}
|
||||
|
||||
@for (limitRow of limits.controls; track limitRow; let i = $index) {
|
||||
<div class="am-limit-row" [formGroup]="$any(limitRow)">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>From Date</mat-label>
|
||||
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>To Date</mat-label>
|
||||
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Limit</mat-label>
|
||||
<input matInput required type="number" formControlName="limit" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="am-remove-guarantor"
|
||||
(click)="removeLimit(i)"
|
||||
matTooltip="Remove limit period"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="pl-card">
|
||||
<div class="pl-toolbar">
|
||||
<div class="pl-search">
|
||||
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
||||
<input type="text" (input)="applyFilter($any($event.target).value)" placeholder="Search by code or name…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<h3>Guarantor's Detail</h3>
|
||||
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
|
||||
<i class="fas fa-plus"></i> Add Guarantor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (guarantors.length === 0) {
|
||||
<p class="am-empty-guarantors">No guarantors added yet.</p>
|
||||
}
|
||||
|
||||
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
|
||||
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID</mat-label>
|
||||
<input matInput required formControlName="clientId" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Guarantor Name</mat-label>
|
||||
<input matInput required formControlName="guarantorName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 1</mat-label>
|
||||
<input matInput required formControlName="address1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 2</mat-label>
|
||||
<input matInput formControlName="address2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>City</mat-label>
|
||||
<input matInput required formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Pin Code</mat-label>
|
||||
<input matInput required formControlName="pinCode" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Telephone</mat-label>
|
||||
<input matInput formControlName="telephone" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="am-remove-guarantor"
|
||||
(click)="removeGuarantor(i)"
|
||||
matTooltip="Remove guarantor"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-wallet"></i>
|
||||
<h3>Deductions & Deposits</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Comm</mat-label>
|
||||
<input matInput formControlName="commission" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Other Comm</mat-label>
|
||||
<input matInput type="number" formControlName="otherCommission" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Ag.Deposit</mat-label>
|
||||
<input matInput type="number" formControlName="agentDeposit" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Adv.Festival</mat-label>
|
||||
<input matInput type="number" formControlName="festivalAdvance" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Other Deduction</mat-label>
|
||||
<input matInput type="number" formControlName="otherDeduction" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="agentForm.invalid" (click)="save()">
|
||||
Save
|
||||
@if (totalCount === 0) {
|
||||
<div class="pl-empty">
|
||||
<div class="pl-empty-ic"><fa-icon icon="user-tie"></fa-icon></div>
|
||||
<h3>No agents yet</h3>
|
||||
<p>Create one so daily collections can be recorded against it.</p>
|
||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>Create Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="pl-table-wrap">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
|
||||
<ng-container matColumnDef="agentCode">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Agent Code</th>
|
||||
<td mat-cell *matCellDef="let a">
|
||||
<div class="pl-product-cell">
|
||||
<div class="pl-product-icon"><fa-icon icon="user-tie"></fa-icon></div>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ a.agentCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="clientName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Agent Name</th>
|
||||
<td mat-cell *matCellDef="let a">{{ a.agentNameId }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Which branch the agent's collections belong to. Its own column rather than a subtitle:
|
||||
it decides which branch's batch run picks the agent up, so it has to be scannable. -->
|
||||
<ng-container matColumnDef="homeBranch">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Branch</th>
|
||||
<td mat-cell *matCellDef="let a">
|
||||
@if (a.homeBranch) {
|
||||
<span class="pl-tag">{{ a.homeBranch }}</span>
|
||||
} @else {
|
||||
<span class="am-no-branch">Not set</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="agentCity">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>City</th>
|
||||
<td mat-cell *matCellDef="let a">{{ a.agentCity }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="agentSavingAccountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Saving A/C No</th>
|
||||
<td mat-cell *matCellDef="let a">
|
||||
<span class="pl-tag">{{ a.agentSavingAccountNo }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="agentCurrentBalance">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Current Balance</th>
|
||||
<td mat-cell *matCellDef="let a" class="num">
|
||||
<strong>{{ a.agentCurrentBalance | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let a">
|
||||
<span class="am-pill" [class.am-pill--off]="!a.active">
|
||||
{{ a.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdDate">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Created</th>
|
||||
<td mat-cell *matCellDef="let a">
|
||||
{{ a.createdDate ? (a.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
|
||||
<td mat-cell *matCellDef="let a" class="num">
|
||||
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
|
||||
@if (a.closeDate) {
|
||||
<span class="am-closed-on">Closed {{ a.closeDate | dateFormat }}</span>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn pl-action-btn--edit"
|
||||
[routerLink]="[a.id, 'edit']"
|
||||
matTooltip="Edit this agent"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon><span>Edit</span>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="pl-row"
|
||||
[class.am-row--inactive]="!row.active"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
@if (filteredCount === 0 && totalCount > 0) {
|
||||
<div class="pl-no-match">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<span>No agents match your filter.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,136 +6,54 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
@use 'assets/styles/product-list' as pl;
|
||||
|
||||
.am-container {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
@include pl.product-list-styles;
|
||||
|
||||
/* Agent-specific indigo accent */
|
||||
:host {
|
||||
--pl-accent: #4338ca;
|
||||
--pl-accent-l: #e0e7ff;
|
||||
}
|
||||
|
||||
.am-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* A retired agent stays listed but reads as switched off. */
|
||||
.am-row--inactive td:not(.num) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.am-back {
|
||||
color: $primary;
|
||||
/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */
|
||||
.pl-action-btn--close {
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.am-card {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
padding: 8px 24px 24px;
|
||||
/* A closed agent shows when, in place of the actions it no longer has. */
|
||||
.am-closed-on {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.am-section {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.am-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 11px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 16px;
|
||||
.am-pill--off {
|
||||
background: rgb(0 0 0 / 7%);
|
||||
color: rgb(0 0 0 / 52%);
|
||||
}
|
||||
|
||||
.am-field {
|
||||
width: 100%;
|
||||
|
||||
&--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.am-add-guarantor {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.am-empty-guarantors {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.am-guarantor-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.am-limit-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.am-remove-guarantor {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.am-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.am-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.am-guarantor-row,
|
||||
.am-limit-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
/* An agent with no branch is an agent no branch's batch run will collect for - say so plainly
|
||||
rather than showing an em dash that reads like "nothing to see here". */
|
||||
.am-no-branch {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
@@ -7,375 +7,122 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTable,
|
||||
MatTableDataSource,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/**
|
||||
* Agent Master Entry component.
|
||||
* Agent Master list.
|
||||
*
|
||||
* A DDS collection agent — either a person or an institution — registered
|
||||
* with its own settlement savings account and one or more guarantors. As of
|
||||
* 2026-08-24 the Agent also does the daily collection work directly (the
|
||||
* SubAgent tier was removed): it carries the collection-limit history,
|
||||
* TDS/commission settings and security account SubAgent used to own.
|
||||
*
|
||||
* Backed by its own DDS Agent API (`/dds/agents`) - independent of Staff/
|
||||
* Client CRUD, per the DDS agent/commission hierarchy being genuinely new
|
||||
* feature work.
|
||||
* The DDS collection agents on file. Follows the same list -> create/edit shape as the product lists
|
||||
* and shares their stylesheet.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-agent-master',
|
||||
templateUrl: './agent-master.component.html',
|
||||
styleUrls: ['./agent-master.component.scss'],
|
||||
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatSort,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatSortHeader,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class AgentMasterComponent {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private router = inject(Router);
|
||||
export class AgentMasterComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
maxDate = new Date();
|
||||
agentId: number | null = null;
|
||||
|
||||
agentSavingAccountTypes = [
|
||||
'Savings Deposit Individual A/C',
|
||||
'Savings Deposit Joint A/C',
|
||||
'Current Account'
|
||||
displayedColumns: string[] = [
|
||||
'agentCode',
|
||||
'clientName',
|
||||
'homeBranch',
|
||||
'agentCity',
|
||||
'agentSavingAccountNo',
|
||||
'agentCurrentBalance',
|
||||
'status',
|
||||
'createdDate',
|
||||
'actions'
|
||||
];
|
||||
|
||||
agentForm: UntypedFormGroup = this.formBuilder.group({
|
||||
clientId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentCode: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
agentNameId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentAddress1: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentAddress2: [''],
|
||||
agentCity: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentPinCode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentTelephone: [''],
|
||||
agentSavingAccountType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentSavingAccountNo: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentOpeningDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentBirthDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentCurrentBalance: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
agentOpeningBalance: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
homeBranch: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
tdsApplicable: [
|
||||
'No',
|
||||
Validators.required
|
||||
],
|
||||
tdsPercent: [0],
|
||||
currentTds: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
tdsAmount: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
savingBranch: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
transferBranch: [''],
|
||||
transferAmountAccountType: [''],
|
||||
transferAccountNo: [''],
|
||||
commissionCalculation: [
|
||||
'Yes',
|
||||
Validators.required
|
||||
],
|
||||
limit: [0],
|
||||
machineRent: [0],
|
||||
agentSecurityBranch: [''],
|
||||
agentSecurityAccountType: [''],
|
||||
agentSecurityAccountNo: [''],
|
||||
commission: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
otherCommission: [0],
|
||||
agentDeposit: [0],
|
||||
festivalAdvance: [0],
|
||||
otherDeduction: [0],
|
||||
limits: this.formBuilder.array([]),
|
||||
guarantors: this.formBuilder.array([])
|
||||
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
||||
agents: any[] = [];
|
||||
filteredCount = 0;
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
|
||||
get totalCount(): number {
|
||||
return this.agents.length;
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.agents.filter((agent) => agent.active).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash the agents are collectively holding but have not settled to a branch yet — an agent's
|
||||
* balance rises on every party collection and falls when it hands the money over. Inactive agents
|
||||
* count too: money still out with a retired agent is exactly what this figure is for.
|
||||
*/
|
||||
get totalCurrentBalance(): number {
|
||||
return this.agents.reduce((sum, agent) => sum + Number(agent.agentCurrentBalance || 0), 0);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAgents();
|
||||
}
|
||||
|
||||
private loadAgents(): void {
|
||||
this.organizationService.getAgents().subscribe((agents: any[]) => {
|
||||
this.agents = agents || [];
|
||||
this.dataSource = new MatTableDataSource(this.agents);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.filteredCount = this.agents.length;
|
||||
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string): void {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
get limits(): UntypedFormArray {
|
||||
return this.agentForm.get('limits') as UntypedFormArray;
|
||||
}
|
||||
|
||||
get guarantors(): UntypedFormArray {
|
||||
return this.agentForm.get('guarantors') as UntypedFormArray;
|
||||
}
|
||||
|
||||
private buildLimitGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
fromDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
toDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
limit: [
|
||||
0,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private buildGuarantorGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
clientId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
guarantorName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
address1: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
address2: [''],
|
||||
city: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
pinCode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
telephone: ['']
|
||||
});
|
||||
}
|
||||
|
||||
addLimit(): void {
|
||||
this.limits.push(this.buildLimitGroup());
|
||||
}
|
||||
|
||||
removeLimit(index: number): void {
|
||||
this.limits.removeAt(index);
|
||||
}
|
||||
|
||||
addGuarantor(): void {
|
||||
this.guarantors.push(this.buildGuarantorGroup());
|
||||
}
|
||||
|
||||
removeGuarantor(index: number): void {
|
||||
this.guarantors.removeAt(index);
|
||||
}
|
||||
|
||||
retrieve(): void {
|
||||
const clientId = this.agentForm.get('clientId')?.value;
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
this.organizationService.getAgents().subscribe((agents: any[]) => {
|
||||
const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId));
|
||||
if (match) {
|
||||
this.patchFormFromAgent(match);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.agentId = null;
|
||||
this.agentForm.reset();
|
||||
this.agentForm.patchValue({
|
||||
agentCurrentBalance: 0,
|
||||
agentOpeningBalance: 0,
|
||||
tdsApplicable: 'No',
|
||||
commissionCalculation: 'Yes',
|
||||
currentTds: 0,
|
||||
tdsAmount: 0,
|
||||
commission: 0
|
||||
});
|
||||
this.limits.clear();
|
||||
this.guarantors.clear();
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.router.navigate(['/organization']);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const raw = this.agentForm.getRawValue();
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
|
||||
const payload: any = {
|
||||
clientId: Number(raw.clientId),
|
||||
agentNameId: raw.agentNameId,
|
||||
agentAddress1: raw.agentAddress1,
|
||||
agentAddress2: raw.agentAddress2 || undefined,
|
||||
agentCity: raw.agentCity,
|
||||
agentPinCode: raw.agentPinCode,
|
||||
agentTelephone: raw.agentTelephone || undefined,
|
||||
agentSavingAccountType: raw.agentSavingAccountType,
|
||||
agentSavingAccountNo: raw.agentSavingAccountNo,
|
||||
agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat),
|
||||
agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat),
|
||||
agentOpeningBalance: raw.agentOpeningBalance || 0,
|
||||
homeBranch: raw.homeBranch,
|
||||
tdsApplicable: raw.tdsApplicable === 'Yes',
|
||||
tdsPercent: raw.tdsPercent || undefined,
|
||||
savingBranch: raw.savingBranch,
|
||||
transferBranch: raw.transferBranch || undefined,
|
||||
transferAmountAccountType: raw.transferAmountAccountType || undefined,
|
||||
transferAccountNo: raw.transferAccountNo || undefined,
|
||||
commissionCalculation: raw.commissionCalculation === 'Yes',
|
||||
limit: raw.limit || undefined,
|
||||
machineRent: raw.machineRent || undefined,
|
||||
agentSecurityBranch: raw.agentSecurityBranch || undefined,
|
||||
agentSecurityAccountType: raw.agentSecurityAccountType || undefined,
|
||||
agentSecurityAccountNo: raw.agentSecurityAccountNo || undefined,
|
||||
otherCommission: raw.otherCommission || undefined,
|
||||
agentDeposit: raw.agentDeposit || undefined,
|
||||
festivalAdvance: raw.festivalAdvance || undefined,
|
||||
otherDeduction: raw.otherDeduction || undefined,
|
||||
dateFormat,
|
||||
locale,
|
||||
limits: (raw.limits || []).map((limit: any) => ({
|
||||
fromDate: this.dateUtils.getDate(limit.fromDate),
|
||||
toDate: this.dateUtils.getDate(limit.toDate),
|
||||
limit: limit.limit
|
||||
})),
|
||||
guarantors: (raw.guarantors || []).map((guarantor: any) => ({
|
||||
clientId: Number(guarantor.clientId),
|
||||
guarantorName: guarantor.guarantorName,
|
||||
address1: guarantor.address1,
|
||||
address2: guarantor.address2 || undefined,
|
||||
city: guarantor.city,
|
||||
pinCode: guarantor.pinCode,
|
||||
telephone: guarantor.telephone || undefined
|
||||
}))
|
||||
};
|
||||
|
||||
if (this.agentId) {
|
||||
this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit());
|
||||
} else {
|
||||
this.organizationService.createAgent(payload).subscribe(() => this.exit());
|
||||
}
|
||||
}
|
||||
|
||||
private patchFormFromAgent(agent: any): void {
|
||||
this.agentId = agent.id;
|
||||
this.agentForm.patchValue({
|
||||
clientId: agent.clientId,
|
||||
agentCode: agent.agentCode,
|
||||
agentNameId: agent.agentNameId,
|
||||
agentAddress1: agent.agentAddress1,
|
||||
agentAddress2: agent.agentAddress2,
|
||||
agentCity: agent.agentCity,
|
||||
agentPinCode: agent.agentPinCode,
|
||||
agentTelephone: agent.agentTelephone,
|
||||
agentSavingAccountType: agent.agentSavingAccountType,
|
||||
agentSavingAccountNo: agent.agentSavingAccountNo,
|
||||
agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null,
|
||||
agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null,
|
||||
agentCurrentBalance: agent.agentCurrentBalance,
|
||||
agentOpeningBalance: agent.agentOpeningBalance,
|
||||
homeBranch: agent.homeBranch,
|
||||
tdsApplicable: agent.tdsApplicable ? 'Yes' : 'No',
|
||||
tdsPercent: agent.tdsPercent,
|
||||
currentTds: agent.currentTds,
|
||||
tdsAmount: agent.tdsAmount,
|
||||
savingBranch: agent.savingBranch,
|
||||
transferBranch: agent.transferBranch,
|
||||
transferAmountAccountType: agent.transferAmountAccountType,
|
||||
transferAccountNo: agent.transferAccountNo,
|
||||
commissionCalculation: agent.commissionCalculation ? 'Yes' : 'No',
|
||||
limit: agent.limit,
|
||||
machineRent: agent.machineRent,
|
||||
agentSecurityBranch: agent.agentSecurityBranch,
|
||||
agentSecurityAccountType: agent.agentSecurityAccountType,
|
||||
agentSecurityAccountNo: agent.agentSecurityAccountNo,
|
||||
commission: agent.commission,
|
||||
otherCommission: agent.otherCommission,
|
||||
agentDeposit: agent.agentDeposit,
|
||||
festivalAdvance: agent.festivalAdvance,
|
||||
otherDeduction: agent.otherDeduction
|
||||
});
|
||||
|
||||
this.limits.clear();
|
||||
(agent.limits || []).forEach((limit: any) => {
|
||||
const group = this.buildLimitGroup();
|
||||
group.patchValue({
|
||||
fromDate: limit.fromDate ? new Date(limit.fromDate) : null,
|
||||
toDate: limit.toDate ? new Date(limit.toDate) : null,
|
||||
limit: limit.limit
|
||||
});
|
||||
this.limits.push(group);
|
||||
});
|
||||
|
||||
this.guarantors.clear();
|
||||
(agent.guarantors || []).forEach((guarantor: any) => {
|
||||
const group = this.buildGuarantorGroup();
|
||||
group.patchValue(guarantor);
|
||||
this.guarantors.push(group);
|
||||
});
|
||||
money(value: any): string {
|
||||
return AgentMasterComponent.MONEY.format(Number(value || 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="am-container">
|
||||
<div class="am-header">
|
||||
<button mat-stroked-button [routerLink]="['../']" class="am-back">
|
||||
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
|
||||
</button>
|
||||
<h2 class="am-header__title">Agent Master Entry</h2>
|
||||
</div>
|
||||
|
||||
<div class="am-card">
|
||||
<form [formGroup]="agentForm">
|
||||
<!--
|
||||
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
|
||||
name, address, city, pincode and telephone from that client, as the legacy screen did, and
|
||||
also defaults the home branch and stocks the two savings-account dropdowns from that client.
|
||||
-->
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-id-card"></i>
|
||||
<h3>Agent Details</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID</mat-label>
|
||||
<input matInput required formControlName="clientId" />
|
||||
@if (clientLookupFailed) {
|
||||
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
|
||||
} @else if (loadedClientName) {
|
||||
<mat-hint>{{ loadedClientName }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Code</mat-label>
|
||||
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Name ID</mat-label>
|
||||
<input matInput required formControlName="agentNameId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 1)</mat-label>
|
||||
<input matInput required formControlName="agentAddress1" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 2)</mat-label>
|
||||
<input matInput formControlName="agentAddress2" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent City</mat-label>
|
||||
<input matInput required formControlName="agentCity" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent PinCode</mat-label>
|
||||
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Telephone</mat-label>
|
||||
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Which branch the agent collects out of. Filled from the client's own office, and
|
||||
changeable, since an agent can be on the books at one branch and work another. The
|
||||
option carries the office's id while showing its name, so renaming a branch later
|
||||
leaves the agent where it is. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Home Branch</mat-label>
|
||||
<mat-select required formControlName="officeId">
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>The branch this agent's collections belong to</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
|
||||
balance left standing on it is this agent's and nobody else's. Required, and one agent
|
||||
to an account - an account already taken is refused by name, not by number. Only
|
||||
detail accounts are offered: a header account is a heading over others and carries no
|
||||
entries of its own. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>GL Account</mat-label>
|
||||
<mat-select required formControlName="glAccountId">
|
||||
@for (account of glAccounts; track account.id) {
|
||||
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Type first, then the number - the order the legacy screen used. The list is built from
|
||||
the client's own accounts, so every type offered is one they actually hold. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C Type</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
formControlName="agentSavingAccountType"
|
||||
(selectionChange)="onSavingsAccountTypePicked()"
|
||||
>
|
||||
@for (accountType of savingsAccountTypes; track accountType) {
|
||||
<mat-option [value]="accountType">{{ accountType }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
|
||||
open them a savings account. Saying which saves a guess. -->
|
||||
@if (savingsAccountTypes.length === 0) {
|
||||
@if (loadedClientName) {
|
||||
<mat-hint class="am-hint--error">
|
||||
{{ loadedClientName }} has no savings account — commission can only be paid into one
|
||||
</mat-hint>
|
||||
} @else {
|
||||
<mat-hint class="am-hint--error">Enter a Client ID first — types come from that client's own accounts</mat-hint>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Picked from the client's own accounts, never typed: commission is paid into this
|
||||
account, and a number that exists only as text is a payment with nowhere to land. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C No</mat-label>
|
||||
<mat-select required formControlName="agentSavingAccountNo">
|
||||
@for (account of accountsOfChosenType; track account.id) {
|
||||
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (accountsOfChosenType.length === 0) {
|
||||
<mat-hint>Choose an A/C Type first</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Date</mat-label>
|
||||
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #openingDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Birth Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
[max]="maxBirthDate"
|
||||
[matDatepicker]="birthDatePicker"
|
||||
formControlName="agentBirthDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #birthDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<h3>Guarantor's Detail</h3>
|
||||
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
|
||||
<i class="fas fa-plus"></i> Add Guarantor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (guarantors.length === 0) {
|
||||
<p class="am-empty-guarantors">No guarantors added yet.</p>
|
||||
}
|
||||
|
||||
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
|
||||
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID</mat-label>
|
||||
<input matInput required formControlName="clientId" />
|
||||
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
|
||||
<mat-error>An agent cannot be its own guarantor</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Guarantor Name</mat-label>
|
||||
<input matInput required formControlName="guarantorName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 1</mat-label>
|
||||
<input matInput required formControlName="address1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 2</mat-label>
|
||||
<input matInput formControlName="address2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>City</mat-label>
|
||||
<input matInput required formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Pin Code</mat-label>
|
||||
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Telephone</mat-label>
|
||||
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="am-remove-guarantor"
|
||||
(click)="removeGuarantor(i)"
|
||||
matTooltip="Remove guarantor"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-balance-scale"></i>
|
||||
<h3>Balances</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Current Balance</mat-label>
|
||||
<input matInput formControlName="agentCurrentBalance" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Balance</mat-label>
|
||||
<input matInput type="number" formControlName="agentOpeningBalance" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="agentForm.invalid" (click)="save()">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
|
||||
.am-container {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.am-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.am-back {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.am-card {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
padding: 8px 24px 24px;
|
||||
}
|
||||
|
||||
.am-section {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.am-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.am-field {
|
||||
width: 100%;
|
||||
|
||||
&--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.am-add-guarantor {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.am-empty-guarantors {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.am-guarantor-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.am-limit-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.am-remove-guarantor {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.am-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
|
||||
.am-hint--error {
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.am-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.am-guarantor-row,
|
||||
.am-limit-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import {
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
UntypedFormArray,
|
||||
Validators,
|
||||
ValidatorFn,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { AccountingService } from 'app/accounting/accounting.service';
|
||||
import { SystemService } from 'app/system/system.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/** Custom Directives */
|
||||
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
|
||||
|
||||
/** Writes a value into a form group only when there is something to write. */
|
||||
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return;
|
||||
}
|
||||
group.get(controlName)?.setValue(String(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Master Entry component.
|
||||
*
|
||||
* A DDS collection agent — either a person or an institution — registered with its own settlement
|
||||
* savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field
|
||||
* for field.
|
||||
*
|
||||
* Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way
|
||||
* the legacy screen did, defaults the home branch to that client's office, and stocks the account
|
||||
* type and account number dropdowns from that client's own accounts. Filled fields stay editable.
|
||||
*
|
||||
* Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS
|
||||
* agent/commission hierarchy being genuinely new feature work.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-create-agent',
|
||||
templateUrl: './create-agent.component.html',
|
||||
styleUrls: ['./create-agent.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
// See the Party form: an unimported directive is ignored without a word.
|
||||
RestrictInputDirective
|
||||
]
|
||||
})
|
||||
export class CreateAgentComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private accountingService = inject(AccountingService);
|
||||
private systemService = inject(SystemService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
/**
|
||||
* The latest date of birth the picker will offer: eighteen years ago today.
|
||||
*
|
||||
* It was previously today, which let a clerk save an agent born on the day they were entered —
|
||||
* the picker opens on today, so a stray Enter was enough. The figure matches the rule the server
|
||||
* applies, so the calendar cannot offer a date the save would then refuse.
|
||||
*/
|
||||
maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate());
|
||||
agentId: number | null = null;
|
||||
|
||||
/** Shown under the Client ID field once a client has been resolved. */
|
||||
loadedClientName = '';
|
||||
clientLookupFailed = false;
|
||||
|
||||
/**
|
||||
* The client's own open savings accounts, fetched once the client is resolved. The commission
|
||||
* account is picked from these rather than typed, so it is always an account that exists and
|
||||
* always belongs to this agent.
|
||||
*/
|
||||
clientSavingsAccounts: any[] = [];
|
||||
|
||||
/**
|
||||
* The account types this client actually holds, taken from their own accounts rather than a fixed
|
||||
* list. A fixed list would offer types the client has nothing under, and its wording would have to
|
||||
* match the product names by hand for the filter below to work at all.
|
||||
*/
|
||||
savingsAccountTypes: string[] = [];
|
||||
|
||||
/** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */
|
||||
private static readonly SAVINGS_DEPOSIT_TYPE = 100;
|
||||
|
||||
/** Branches, for the Home Branch field. Defaulted from the client's own office. */
|
||||
offices: any[] = [];
|
||||
|
||||
/**
|
||||
* The ledger accounts an agent's collections may pass through. Header accounts are left out: they
|
||||
* are headings over other accounts and carry no entries, so offering one only leads to a refusal.
|
||||
*/
|
||||
glAccounts: any[] = [];
|
||||
|
||||
/**
|
||||
* The savings product a DDS customer's own scheme account is opened under, read from the setting
|
||||
* that names it rather than guessed from a product name a bank is free to change.
|
||||
*
|
||||
* Held so those accounts can be kept out of the agent's own list. They are savings accounts and
|
||||
* pass every other test, but they belong to a customer's scheme: commission paid into one would be
|
||||
* counted as that customer's deposits by every interest run afterwards. The server refuses it
|
||||
* either way - this is so the choice is never offered rather than taken and then thrown back.
|
||||
*/
|
||||
private ddsProductId: number | null = null;
|
||||
|
||||
/**
|
||||
* The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen
|
||||
* did, so the officer narrows to a kind of account and then picks one of that kind.
|
||||
*/
|
||||
get accountsOfChosenType(): any[] {
|
||||
const chosenType = this.agentForm?.get('agentSavingAccountType')?.value;
|
||||
if (!chosenType) {
|
||||
return [];
|
||||
}
|
||||
return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType);
|
||||
}
|
||||
|
||||
agentForm: UntypedFormGroup = this.formBuilder.group({
|
||||
clientId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentCode: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
agentNameId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentAddress1: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentAddress2: [''],
|
||||
agentCity: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentPinCode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentTelephone: [''],
|
||||
// Which branch this agent collects out of. Holds the office's id, not its name - the name is
|
||||
// only what the dropdown shows. The DDS batch runs filter by it.
|
||||
officeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
// Asked before the account number, which is then narrowed to accounts of this type.
|
||||
agentSavingAccountType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentSavingAccountNo: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentOpeningDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentBirthDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
agentCurrentBalance: [
|
||||
{
|
||||
value: 0,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
agentOpeningBalance: [0],
|
||||
/*
|
||||
* Required. An agent sharing the scheme's one account leaves a balance that says money is
|
||||
* outstanding without saying whose - which is exactly what this field was added to answer.
|
||||
*/
|
||||
glAccountId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
guarantors: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.organizationService.getOffices().subscribe({
|
||||
next: (offices: any) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []),
|
||||
error: () => (this.offices = [])
|
||||
});
|
||||
|
||||
this.systemService.getConfigurationByName('dds-deposit-product').subscribe({
|
||||
next: (config: any) => {
|
||||
this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null;
|
||||
// The client may already have been keyed while this was in flight, so the list is rebuilt
|
||||
// rather than left showing an account this now knows to exclude.
|
||||
const clientId = this.agentForm.get('clientId')?.value;
|
||||
if (clientId) { this.loadClientSavingsAccounts(String(clientId)); }
|
||||
},
|
||||
error: () => (this.ddsProductId = null)
|
||||
});
|
||||
|
||||
this.accountingService.getChartOfAccounts().subscribe({
|
||||
next: (accounts: any) => {
|
||||
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
|
||||
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
|
||||
// agent pointed at one would fail on their first collection rather than here.
|
||||
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled);
|
||||
},
|
||||
error: () => (this.glAccounts = [])
|
||||
});
|
||||
|
||||
// An id in the route means this is an edit; load that agent into the form.
|
||||
const routeId = this.route.snapshot.paramMap.get('agentId');
|
||||
if (routeId) {
|
||||
this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent));
|
||||
}
|
||||
|
||||
// Fill the client-derived fields as soon as typing settles - no need to tab out of the field.
|
||||
this.agentForm
|
||||
.get('clientId')
|
||||
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
this.loadClient();
|
||||
// A guarantor row that was fine may now be the agent itself, or the other way round.
|
||||
for (const row of this.guarantors.controls) {
|
||||
row.get('clientId')?.updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get guarantors(): UntypedFormArray {
|
||||
return this.agentForm.get('guarantors') as UntypedFormArray;
|
||||
}
|
||||
|
||||
private buildGuarantorGroup(): UntypedFormGroup {
|
||||
const group = this.formBuilder.group({
|
||||
clientId: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
this.notTheAgentItself()
|
||||
]
|
||||
],
|
||||
guarantorName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
address1: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
address2: [''],
|
||||
city: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
pinCode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
telephone: ['']
|
||||
});
|
||||
|
||||
// A guarantor is a client too, so its row fills from the client the same way the agent's does.
|
||||
group
|
||||
.get('clientId')
|
||||
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
|
||||
.subscribe((clientId) => this.loadGuarantorClient(group, clientId));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes
|
||||
* from someone else.
|
||||
*/
|
||||
private notTheAgentItself(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const agentClientId = this.agentForm?.get('clientId')?.value;
|
||||
if (!control.value || !agentClientId) {
|
||||
return null;
|
||||
}
|
||||
return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */
|
||||
private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void {
|
||||
for (const controlName of ['guarantorName', 'telephone', 'address1', 'address2', 'city', 'pinCode']) {
|
||||
group.get(controlName)?.setValue('');
|
||||
}
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientsService.getClientData(String(clientId), true).subscribe({
|
||||
next: (client: any) => {
|
||||
setGroupField(group, 'guarantorName', client?.displayName);
|
||||
setGroupField(group, 'telephone', client?.mobileNo);
|
||||
this.clientsService.getClientAddressData(String(clientId), true).subscribe({
|
||||
next: (addresses: any) => {
|
||||
const address = Array.isArray(addresses) ? addresses[0] : addresses;
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
setGroupField(group, 'address1', address.addressLine1 || address.street);
|
||||
setGroupField(group, 'address2', address.addressLine2);
|
||||
setGroupField(group, 'city', address.city);
|
||||
setGroupField(group, 'pinCode', address.postalCode);
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
},
|
||||
// An id that matches no client just leaves the row for the officer to fill in.
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
addGuarantor(): void {
|
||||
this.guarantors.push(this.buildGuarantorGroup());
|
||||
}
|
||||
|
||||
removeGuarantor(index: number): void {
|
||||
this.guarantors.removeAt(index);
|
||||
}
|
||||
|
||||
/** The fields the legacy screen filled from the client, rather than asking the officer for. */
|
||||
private static readonly CLIENT_DERIVED_FIELDS = [
|
||||
'agentNameId',
|
||||
'agentTelephone',
|
||||
'agentAddress1',
|
||||
'agentAddress2',
|
||||
'agentCity',
|
||||
'agentPinCode'
|
||||
];
|
||||
|
||||
/**
|
||||
* Refills the client-derived fields whenever the Client ID changes, as the legacy screen did.
|
||||
* They always show the client currently entered: changing the id replaces them, clearing it
|
||||
* empties them.
|
||||
*/
|
||||
loadClient(): void {
|
||||
if (this.agentId) {
|
||||
return;
|
||||
}
|
||||
this.clearClientDerivedFields();
|
||||
this.loadedClientName = '';
|
||||
this.clientLookupFailed = false;
|
||||
// A different client has different accounts, so anything picked for the previous one is stale.
|
||||
this.clientSavingsAccounts = [];
|
||||
this.savingsAccountTypes = [];
|
||||
this.agentForm.get('agentSavingAccountType')?.setValue('');
|
||||
this.agentForm.get('agentSavingAccountNo')?.setValue('');
|
||||
|
||||
const clientId = this.agentForm.get('clientId')?.value;
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientsService.getClientData(String(clientId), true).subscribe({
|
||||
next: (client: any) => {
|
||||
this.loadedClientName = client?.displayName || '';
|
||||
this.setField('agentNameId', client?.displayName);
|
||||
this.setField('agentTelephone', client?.mobileNo);
|
||||
// The branch the person is on the bank's books at. Offered as a starting point rather than
|
||||
// forced, since an agent can be recorded at one office and collect out of another. Set as a
|
||||
// number rather than through setField, whose String() would stop it matching an option.
|
||||
if (client?.officeId) {
|
||||
this.agentForm.get('officeId')?.setValue(Number(client.officeId));
|
||||
}
|
||||
this.loadClientAddress(String(clientId));
|
||||
this.loadClientSavingsAccounts(String(clientId));
|
||||
},
|
||||
error: () => {
|
||||
this.clientLookupFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's own open savings accounts, to be picked from rather than typed.
|
||||
*
|
||||
* The account number used to be free text, and the live data showed the cost: agent AG0001 carried
|
||||
* "1234567890", which matches no account at all - so every commission recorded against it had
|
||||
* nowhere to go. Offering only the client's real accounts makes that impossible to key, and the
|
||||
* account number then carries its correct product prefix by construction rather than by memory.
|
||||
*/
|
||||
private loadClientSavingsAccounts(clientId: string): void {
|
||||
this.clientsService.getClientAccountData(clientId).subscribe({
|
||||
next: (accounts: any) => {
|
||||
this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter(
|
||||
(account: any) =>
|
||||
// Savings only. Commission is credited whenever a run posts, and a fixed deposit is a
|
||||
// single sum placed for a term while a recurring one takes only its own instalments -
|
||||
// neither accepts the credit, so the posting would fail rather than the choice.
|
||||
// A DDS scheme account is a savings account too and so passes the test above. It is
|
||||
// dropped by the product the setting names - not by a product called "DDS", which is a
|
||||
// name the bank may change - so the list offers only accounts the agent can be paid in.
|
||||
account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
|
||||
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
|
||||
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
|
||||
);
|
||||
this.savingsAccountTypes = [
|
||||
...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean))
|
||||
];
|
||||
},
|
||||
error: () => {
|
||||
this.clientSavingsAccounts = [];
|
||||
this.savingsAccountTypes = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the account number when the type changes.
|
||||
*
|
||||
* Without this the previously picked account stays in the field while the list below it now shows
|
||||
* a different type - the form would submit an account that contradicts its own type.
|
||||
*/
|
||||
onSavingsAccountTypePicked(): void {
|
||||
this.agentForm.get('agentSavingAccountNo')?.setValue('');
|
||||
}
|
||||
|
||||
private loadClientAddress(clientId: string): void {
|
||||
this.clientsService.getClientAddressData(clientId, true).subscribe({
|
||||
next: (addresses: any) => {
|
||||
const address = Array.isArray(addresses) ? addresses[0] : addresses;
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
this.setField('agentAddress1', address.addressLine1 || address.street);
|
||||
this.setField('agentAddress2', address.addressLine2);
|
||||
this.setField('agentCity', address.city);
|
||||
this.setField('agentPinCode', address.postalCode);
|
||||
},
|
||||
// A client with no address on file is normal — the officer just types it.
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
private clearClientDerivedFields(): void {
|
||||
for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) {
|
||||
this.agentForm.get(controlName)?.setValue('');
|
||||
}
|
||||
}
|
||||
|
||||
private setField(controlName: string, value: any): void {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return;
|
||||
}
|
||||
this.agentForm.get(controlName)?.setValue(String(value));
|
||||
}
|
||||
|
||||
retrieve(): void {
|
||||
const clientId = this.agentForm.get('clientId')?.value;
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
this.organizationService.getAgents().subscribe((agents: any[]) => {
|
||||
const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId));
|
||||
if (match) {
|
||||
this.patchFormFromAgent(match);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.agentId = null;
|
||||
this.loadedClientName = '';
|
||||
this.clientLookupFailed = false;
|
||||
this.agentForm.reset();
|
||||
this.agentForm.patchValue({
|
||||
agentCurrentBalance: 0,
|
||||
agentOpeningBalance: 0
|
||||
});
|
||||
this.guarantors.clear();
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.router.navigate(['/organization/agent-master']);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const raw = this.agentForm.getRawValue();
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
|
||||
const payload: any = {
|
||||
clientId: Number(raw.clientId),
|
||||
agentNameId: raw.agentNameId,
|
||||
agentAddress1: raw.agentAddress1,
|
||||
agentAddress2: raw.agentAddress2 || undefined,
|
||||
agentCity: raw.agentCity,
|
||||
agentPinCode: raw.agentPinCode,
|
||||
agentTelephone: raw.agentTelephone || undefined,
|
||||
officeId: Number(raw.officeId),
|
||||
glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined,
|
||||
agentSavingAccountType: raw.agentSavingAccountType,
|
||||
agentSavingAccountNo: raw.agentSavingAccountNo,
|
||||
agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat),
|
||||
agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat),
|
||||
agentOpeningBalance: raw.agentOpeningBalance || 0,
|
||||
dateFormat,
|
||||
locale,
|
||||
guarantors: (raw.guarantors || []).map((guarantor: any) => ({
|
||||
clientId: Number(guarantor.clientId),
|
||||
guarantorName: guarantor.guarantorName,
|
||||
address1: guarantor.address1,
|
||||
address2: guarantor.address2 || undefined,
|
||||
city: guarantor.city,
|
||||
pinCode: guarantor.pinCode,
|
||||
telephone: guarantor.telephone || undefined
|
||||
}))
|
||||
};
|
||||
|
||||
if (this.agentId) {
|
||||
this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit());
|
||||
} else {
|
||||
this.organizationService.createAgent(payload).subscribe(() => this.exit());
|
||||
}
|
||||
}
|
||||
|
||||
private patchFormFromAgent(agent: any): void {
|
||||
this.agentId = agent.id;
|
||||
this.loadedClientName = agent.clientName || '';
|
||||
this.clientLookupFailed = false;
|
||||
this.agentForm.patchValue({
|
||||
clientId: agent.clientId,
|
||||
agentCode: agent.agentCode,
|
||||
agentNameId: agent.agentNameId,
|
||||
agentAddress1: agent.agentAddress1,
|
||||
agentAddress2: agent.agentAddress2,
|
||||
agentCity: agent.agentCity,
|
||||
agentPinCode: agent.agentPinCode,
|
||||
agentTelephone: agent.agentTelephone,
|
||||
officeId: agent.officeId ? Number(agent.officeId) : '',
|
||||
glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '',
|
||||
agentSavingAccountType: agent.agentSavingAccountType,
|
||||
agentSavingAccountNo: agent.agentSavingAccountNo,
|
||||
agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null,
|
||||
agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null,
|
||||
agentCurrentBalance: agent.agentCurrentBalance,
|
||||
agentOpeningBalance: agent.agentOpeningBalance
|
||||
});
|
||||
|
||||
// The account list is needed for the dropdown to show the already-saved account as chosen
|
||||
// rather than blank, so it is fetched on edit as well as on the client lookup.
|
||||
if (agent.clientId) {
|
||||
this.loadClientSavingsAccounts(String(agent.clientId));
|
||||
}
|
||||
|
||||
this.guarantors.clear();
|
||||
for (const guarantor of agent.guarantors || []) {
|
||||
const group = this.buildGuarantorGroup();
|
||||
group.patchValue({
|
||||
clientId: guarantor.clientId,
|
||||
guarantorName: guarantor.guarantorName,
|
||||
address1: guarantor.address1,
|
||||
address2: guarantor.address2,
|
||||
city: guarantor.city,
|
||||
pinCode: guarantor.pinCode,
|
||||
telephone: guarantor.telephone
|
||||
});
|
||||
this.guarantors.push(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,67 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (slab of slabs; track slab; let i = $index) {
|
||||
<tr>
|
||||
<td>{{ slab.slabType }}</td>
|
||||
<td>{{ slab.slabFrom }}</td>
|
||||
<td>{{ slab.slabTo }}</td>
|
||||
<td>{{ slab.commissionPercent }}%</td>
|
||||
<td class="csm-row-actions">
|
||||
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove slab">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (editingIndex === i) {
|
||||
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
|
||||
<tr [formGroup]="editSlabForm" class="csm-new-row">
|
||||
<td>
|
||||
<mat-form-field appearance="outline" class="csm-inline-field">
|
||||
<mat-select formControlName="slabType">
|
||||
@for (type of slabTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td>
|
||||
<mat-form-field appearance="outline" class="csm-inline-field">
|
||||
<input matInput type="number" formControlName="slabFrom" />
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td>
|
||||
<mat-form-field appearance="outline" class="csm-inline-field">
|
||||
<input matInput type="number" formControlName="slabTo" />
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td>
|
||||
<mat-form-field appearance="outline" class="csm-inline-field">
|
||||
<input matInput type="number" formControlName="commissionPercent" />
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td class="csm-row-actions">
|
||||
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<tr>
|
||||
<td>{{ slab.slabType }}</td>
|
||||
<td>{{ slab.slabFrom }}</td>
|
||||
<td>{{ slab.slabTo }}</td>
|
||||
<td>{{ slab.commissionPercent }}%</td>
|
||||
<td class="csm-row-actions">
|
||||
@if (slab.id) {
|
||||
<!--
|
||||
A saved slab is corrected, never removed. Deleting a range would leave a hole
|
||||
that pays nothing to any agent whose volume lands in it, with nothing on screen
|
||||
to say why - and there is deliberately no delete endpoint behind it.
|
||||
-->
|
||||
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
} @else {
|
||||
<!-- Not saved yet, so taking it back off the table costs nothing. -->
|
||||
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
<tr [formGroup]="newSlabForm" class="csm-new-row">
|
||||
@@ -66,11 +116,15 @@
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td class="csm-row-actions">
|
||||
<!--
|
||||
Deliberately not disabled while the row is incomplete. A greyed-out button says the
|
||||
screen will not accept this, but never which field it is waiting for; letting the
|
||||
click through means addSlab() can say so.
|
||||
-->
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="newSlabForm.invalid"
|
||||
(click)="addSlab()"
|
||||
matTooltip="Add slab"
|
||||
>
|
||||
@@ -85,6 +139,24 @@
|
||||
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
|
||||
}
|
||||
|
||||
@if (coverage.length > 0) {
|
||||
<div class="csm-coverage">
|
||||
@for (entry of coverage; track entry.slabType) {
|
||||
<div class="csm-coverage__row">
|
||||
<span class="csm-coverage__type">{{ entry.slabType }}</span>
|
||||
<span class="csm-coverage__range">
|
||||
covers {{ entry.from | number }} to {{ entry.to | number }}
|
||||
</span>
|
||||
@if (entry.gaps.length > 0) {
|
||||
<span class="csm-coverage__gap">
|
||||
nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="csm-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
$primary: #0f766e;
|
||||
|
||||
.csm-container {
|
||||
max-width: 760px;
|
||||
// Four inputs sit side by side in the entry row; 760px squeezed them out of the card.
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
@@ -41,6 +42,14 @@ $primary: #0f766e;
|
||||
.csm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
|
||||
// Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in.
|
||||
th:nth-child(1), td:nth-child(1) { width: 26%; }
|
||||
th:nth-child(2), td:nth-child(2) { width: 22%; }
|
||||
th:nth-child(3), td:nth-child(3) { width: 22%; }
|
||||
th:nth-child(4), td:nth-child(4) { width: 18%; }
|
||||
th:nth-child(5), td:nth-child(5) { width: 12%; }
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
@@ -61,6 +70,8 @@ $primary: #0f766e;
|
||||
|
||||
.csm-inline-field {
|
||||
width: 100%;
|
||||
// Without this the field holds its own 180px floor and pushes the next column out of the card.
|
||||
min-width: 0;
|
||||
|
||||
::ng-deep .mat-mdc-text-field-wrapper {
|
||||
height: 44px;
|
||||
@@ -90,3 +101,44 @@ $primary: #0f766e;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
/*
|
||||
* What the slabs actually cover, and any figure between them that none of them claims.
|
||||
*
|
||||
* A gap is legitimate - a bank may set no commission below some floor - so this reports rather than
|
||||
* warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and
|
||||
* moves on, so the agent is simply paid nothing that month and nothing on screen explains it.
|
||||
*/
|
||||
.csm-coverage {
|
||||
margin-top: 14px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgb(0 0 0 / 12%);
|
||||
border-radius: 6px;
|
||||
background: rgb(0 0 0 / 2.5%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-weight: 600;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
&__range {
|
||||
color: rgb(0 0 0 / 62%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__gap {
|
||||
color: #b45309;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
|
||||
/**
|
||||
* Commission Slab Master component.
|
||||
@@ -39,6 +40,7 @@ export class CommissionSlabMasterComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private router = inject(Router);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
private static readonly SLAB_TYPE_CODES: Record<string, string> = {
|
||||
Account: 'ACCOUNT',
|
||||
@@ -99,11 +101,85 @@ export class CommissionSlabMasterComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each slab type's coverage begins and ends, and any figure in between that no slab claims.
|
||||
*
|
||||
* A gap is not an error - a bank may well decide there is no commission below some floor - but it
|
||||
* has to be visible. The commission run simply finds no slab for a volume that falls in a gap and
|
||||
* moves on, so the agent is paid nothing for that month with nothing on screen to say why.
|
||||
*/
|
||||
get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> {
|
||||
return this.slabTypes
|
||||
.map((slabType) => {
|
||||
const rows = this.slabs
|
||||
.filter((slab) => slab.slabType === slabType)
|
||||
.map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) }))
|
||||
.sort((a, b) => a.from - b.from);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gaps: string[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const previousEnd = rows[i - 1].to;
|
||||
const nextStart = rows[i].from;
|
||||
if (nextStart > previousEnd + 1) {
|
||||
gaps.push(`${previousEnd + 1} - ${nextStart - 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps };
|
||||
})
|
||||
.filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The slab already on the table that this range would collide with, if any.
|
||||
*
|
||||
* The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may
|
||||
* have keyed several rows and has to work out which one it objected to. Catching it as the row is
|
||||
* added names the clash while it is still the row in front of them.
|
||||
*/
|
||||
private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any {
|
||||
return this.slabs.find(
|
||||
(slab) =>
|
||||
slab.slabType === candidate.slabType
|
||||
&& Number(candidate.slabFrom) <= Number(slab.slabTo)
|
||||
&& Number(candidate.slabTo) >= Number(slab.slabFrom)
|
||||
);
|
||||
}
|
||||
|
||||
addSlab(): void {
|
||||
if (this.newSlabForm.invalid) {
|
||||
// Marking the controls touched is what makes Material show which one is missing; without it
|
||||
// the button simply does nothing and the officer has no idea what the screen is waiting for.
|
||||
this.newSlabForm.markAllAsTouched();
|
||||
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' });
|
||||
return;
|
||||
}
|
||||
this.slabs.push(this.newSlabForm.getRawValue());
|
||||
|
||||
const candidate = this.newSlabForm.getRawValue();
|
||||
|
||||
if (Number(candidate.slabFrom) > Number(candidate.slabTo)) {
|
||||
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
|
||||
return;
|
||||
}
|
||||
if (Number(candidate.commissionPercent) > 100) {
|
||||
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clash = this.clashingSlab(candidate);
|
||||
if (clash) {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.slabs.push(candidate);
|
||||
this.newSlabForm.reset({
|
||||
slabType: '',
|
||||
slabFrom: 0,
|
||||
@@ -112,10 +188,92 @@ export class CommissionSlabMasterComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Only ever an unsaved row - a saved slab is corrected, not taken away. */
|
||||
removeSlab(index: number): void {
|
||||
this.slabs.splice(index, 1);
|
||||
}
|
||||
|
||||
/** Which saved row is currently open for correction, or -1 when none is. */
|
||||
editingIndex = -1;
|
||||
|
||||
editSlabForm: UntypedFormGroup = this.formBuilder.group({
|
||||
slabType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
slabFrom: [
|
||||
0,
|
||||
Validators.required
|
||||
],
|
||||
slabTo: [
|
||||
0,
|
||||
Validators.required
|
||||
],
|
||||
commissionPercent: [
|
||||
0,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
startEdit(index: number): void {
|
||||
const slab = this.slabs[index];
|
||||
this.editingIndex = index;
|
||||
this.editSlabForm.setValue({
|
||||
slabType: slab.slabType,
|
||||
slabFrom: slab.slabFrom,
|
||||
slabTo: slab.slabTo,
|
||||
commissionPercent: slab.commissionPercent
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingIndex = -1;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
const slab = this.slabs[this.editingIndex];
|
||||
const corrected = this.editSlabForm.getRawValue();
|
||||
|
||||
if (this.editSlabForm.invalid) {
|
||||
this.editSlabForm.markAllAsTouched();
|
||||
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' });
|
||||
return;
|
||||
}
|
||||
if (Number(corrected.slabFrom) > Number(corrected.slabTo)) {
|
||||
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
|
||||
return;
|
||||
}
|
||||
if (Number(corrected.commissionPercent) > 100) {
|
||||
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.organizationService
|
||||
.updateCommissionSlab(slab.id, {
|
||||
slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType,
|
||||
slabFrom: corrected.slabFrom,
|
||||
slabTo: corrected.slabTo,
|
||||
commissionPercent: corrected.commissionPercent,
|
||||
locale: 'en'
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
|
||||
this.editingIndex = -1;
|
||||
this.loadSlabs();
|
||||
},
|
||||
// The backend refuses a correction that overlaps another slab; the reason has to reach the
|
||||
// officer, not vanish.
|
||||
error: (error: any) => {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message:
|
||||
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.router.navigate(['/organization']);
|
||||
}
|
||||
@@ -123,6 +281,12 @@ export class CommissionSlabMasterComponent implements OnInit {
|
||||
save(): void {
|
||||
const newSlabs = this.slabs.filter((slab) => !slab.id);
|
||||
if (newSlabs.length === 0) {
|
||||
// Save used to return here without a word, so a slab typed into the entry row but never added
|
||||
// looked exactly like a slab that had been saved.
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message: 'Nothing new to save — add the slab to the table with + first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,6 +297,23 @@ export class CommissionSlabMasterComponent implements OnInit {
|
||||
commissionPercent: slab.commissionPercent
|
||||
}));
|
||||
|
||||
this.organizationService.createCommissionSlabs(payload).subscribe(() => this.loadSlabs());
|
||||
this.organizationService.createCommissionSlabs(payload).subscribe({
|
||||
next: () => {
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
|
||||
});
|
||||
this.loadSlabs();
|
||||
},
|
||||
// The backend refuses an overlapping range; the officer needs to read why, not watch nothing
|
||||
// happen and assume the save went through.
|
||||
error: (error: any) => {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message:
|
||||
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<button mat-stroked-button [routerLink]="['../']" class="bp-back">
|
||||
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
|
||||
</button>
|
||||
<h2 class="bp-header__title">DDS Interest & Commission Batch Processing</h2>
|
||||
<h2 class="bp-header__title">DDS Interest & Commission</h2>
|
||||
</div>
|
||||
|
||||
<div class="bp-card">
|
||||
@@ -20,8 +20,8 @@
|
||||
<mat-form-field appearance="outline" class="bp-field bp-field--wide">
|
||||
<mat-label>Job Type</mat-label>
|
||||
<mat-select required formControlName="jobType">
|
||||
@for (type of jobTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
@for (type of jobTypes; track type.code) {
|
||||
<mat-option [value]="type.code">{{ type.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="bp-field">
|
||||
<mat-label>{{ isPostingJob ? 'Posting Date' : 'As-On Date' }}</mat-label>
|
||||
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
|
||||
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #jobDatePicker></mat-datepicker>
|
||||
@@ -65,69 +65,234 @@
|
||||
<input matInput required type="number" formControlName="agentTo" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="bp-field">
|
||||
<mat-label>Party From</mat-label>
|
||||
<input matInput required type="number" formControlName="partyFrom" />
|
||||
</mat-form-field>
|
||||
<!-- Commission is worked out per agent on their whole collection, so a party range would
|
||||
imply a filter that has no effect. -->
|
||||
@if (!isCommissionJob) {
|
||||
<mat-form-field appearance="outline" class="bp-field">
|
||||
<mat-label>Party From</mat-label>
|
||||
<input matInput required type="number" formControlName="partyFrom" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="bp-field">
|
||||
<mat-label>Party To</mat-label>
|
||||
<input matInput required type="number" formControlName="partyTo" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="bp-field">
|
||||
<mat-label>Party To</mat-label>
|
||||
<input matInput required type="number" formControlName="partyTo" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="bp-callout">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>
|
||||
<strong>Generate</strong> runs a preview only — nothing is applied to any account. <strong>Post</strong> is a
|
||||
separate, explicit step that commits the previewed amounts.
|
||||
@if (isPostingJob) {
|
||||
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
|
||||
customer's balance, or out to the agent. <strong>Generate</strong> lists it first; nothing moves until you
|
||||
press <strong>Post</strong>.
|
||||
} @else {
|
||||
<strong>{{ jobTypeLabel }}</strong> works out what has been earned since the last run and records it against
|
||||
each account. It does not become spendable until the matching Posting job runs.
|
||||
<strong>Generate</strong> previews it; nothing is recorded until you press <strong>Post</strong>.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (hasGenerated) {
|
||||
@if (generatedRun) {
|
||||
<div class="bp-section">
|
||||
<div class="bp-section__header">
|
||||
<i class="fas fa-table"></i>
|
||||
<h3>Preview</h3>
|
||||
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
|
||||
<span class="bp-section__note">
|
||||
Run #{{ generatedRun.id }} · nothing applied yet
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (previewRows.length === 0) {
|
||||
<p class="bp-empty">
|
||||
No calculation preview available yet — this appears once the backend batch-job endpoint for "{{
|
||||
batchForm.get('jobType').value
|
||||
}}" is connected.
|
||||
Nothing to apply. Either this period has already been processed, or there was no activity in it.
|
||||
</p>
|
||||
} @else {
|
||||
<table class="bp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Party</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of previewRows; track row) {
|
||||
<tr>
|
||||
<td>{{ row.agent }}</td>
|
||||
<td>{{ row.party }}</td>
|
||||
<td>{{ row.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="bp-table-scroll">
|
||||
@if (showsCommissionBreakdown) {
|
||||
<table class="bp-table bp-table--breakdown">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Period</th>
|
||||
<th class="bp-num">Collection</th>
|
||||
<th class="bp-num">Rate</th>
|
||||
<th class="bp-num">Commission</th>
|
||||
<th class="bp-num">TDS</th>
|
||||
<th class="bp-num">Deductions</th>
|
||||
<th class="bp-num">Payable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of previewRows; track row.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="bp-code">{{ row.agentCode }}</span>
|
||||
<span class="bp-sub">{{ row.agentName }}</span>
|
||||
</td>
|
||||
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
|
||||
<td class="bp-num">{{ money(row.baseAmount) }}</td>
|
||||
<td class="bp-num">{{ row.ratePercent }}%</td>
|
||||
<td class="bp-num">{{ money(row.grossAmount) }}</td>
|
||||
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
|
||||
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
|
||||
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="7">Total payable</td>
|
||||
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
} @else {
|
||||
<table class="bp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ isCommissionJob ? 'Agent' : 'Party' }}</th>
|
||||
@if (!isCommissionJob) {
|
||||
<th>Agent</th>
|
||||
}
|
||||
<th>Period</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of previewRows; track row.id) {
|
||||
<tr>
|
||||
<td>
|
||||
@if (isCommissionJob) {
|
||||
<span class="bp-code">{{ row.agentCode }}</span>
|
||||
<span class="bp-sub">{{ row.agentName }}</span>
|
||||
} @else {
|
||||
<span class="bp-code">{{ row.partyCode }}</span>
|
||||
<span class="bp-sub">{{ row.partyName }}</span>
|
||||
}
|
||||
</td>
|
||||
@if (!isCommissionJob) {
|
||||
<td>{{ row.agentCode }}</td>
|
||||
}
|
||||
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
|
||||
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td [attr.colspan]="isCommissionJob ? 2 : 3">Total</td>
|
||||
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bp-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
@if (generatedRun) {
|
||||
<button mat-stroked-button type="button" (click)="discard()">Discard</button>
|
||||
}
|
||||
<button mat-stroked-button type="button" [disabled]="batchForm.invalid" (click)="generate()">
|
||||
<i class="fas fa-play" style="margin-right: 6px"></i> Generate
|
||||
<i class="fas fa-calculator"></i> Generate
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="!hasGenerated" (click)="post()">
|
||||
<i class="fas fa-check" style="margin-right: 6px"></i> Post
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!generatedRun || previewRows.length === 0"
|
||||
(click)="post()"
|
||||
>
|
||||
<i class="fas fa-check"></i> Post
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
A crediting run costs what a savings deposit costs on every account it touches, so the server does
|
||||
it in the background and answers at once. Without this the screen would report a run as posted
|
||||
seconds after it began, and the clerk would go looking for credits still being written.
|
||||
-->
|
||||
@if (postingRunId) {
|
||||
<div class="bp-running">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
<span>
|
||||
<strong>Posting is running.</strong>
|
||||
It continues even if you leave this screen — the list below updates when it finishes.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is
|
||||
no way to tell whether a month's interest was ever actually posted. -->
|
||||
<div class="bp-card">
|
||||
<div class="bp-section__header">
|
||||
<i class="fas fa-history"></i>
|
||||
<h3>Previous runs</h3>
|
||||
</div>
|
||||
|
||||
@if (recentRuns.length === 0) {
|
||||
<p class="bp-empty">No batch has been run yet.</p>
|
||||
} @else {
|
||||
<div class="bp-table-scroll">
|
||||
<table class="bp-table bp-table--runs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Job</th>
|
||||
<th>Date</th>
|
||||
<th class="bp-num">Entries</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Posted On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of recentRuns; track run.id) {
|
||||
<tr>
|
||||
<td>{{ run.id }}</td>
|
||||
<td>{{ labelOf(run.jobType) }}</td>
|
||||
<td>{{ run.jobDate | dateFormat }}</td>
|
||||
<td class="bp-num">{{ run.totalRows }}</td>
|
||||
<td class="bp-num">{{ money(run.totalAmount) }}</td>
|
||||
<!--
|
||||
Four states, not two. A run that is still crediting is not "not posted" — that
|
||||
reads as a run somebody forgot, and a clerk would run it again on top of the one
|
||||
already going. One that failed says so, with the reason the server recorded.
|
||||
-->
|
||||
<td>
|
||||
@switch (run.status) {
|
||||
@case ('POSTED') {
|
||||
<span class="bp-status bp-status--posted">Posted</span>
|
||||
}
|
||||
@case ('POSTING') {
|
||||
<span class="bp-status bp-status--running">
|
||||
<i class="fas fa-circle-notch fa-spin"></i> Running
|
||||
</span>
|
||||
}
|
||||
@case ('FAILED') {
|
||||
<span class="bp-status bp-status--failed" [matTooltip]="run.failureReason || 'No reason recorded'">
|
||||
Failed
|
||||
</span>
|
||||
}
|
||||
@default {
|
||||
<span class="bp-status">Not posted</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,20 @@
|
||||
$primary: #0f766e;
|
||||
|
||||
.bp-container {
|
||||
max-width: 900px;
|
||||
// The commission breakdown carries eight columns; anything narrower makes it scroll on every
|
||||
// screen rather than only on small ones.
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.bp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
@@ -95,8 +99,15 @@ $primary: #0f766e;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
.bp-empty {
|
||||
@@ -105,23 +116,118 @@ $primary: #0f766e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// A wide table scrolls inside its own box rather than pushing the whole page sideways.
|
||||
.bp-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
padding: 8px 10px;
|
||||
border-bottom: 2px solid rgb(0 0 0 / 10%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
border-bottom: none;
|
||||
border-top: 2px solid rgb(0 0 0 / 10%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Figures line up column-wise so a wrong order of magnitude is visible at a glance.
|
||||
.bp-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bp-minus {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.bp-total {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bp-period {
|
||||
white-space: nowrap;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.bp-code {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bp-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.bp-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgb(180 83 9 / 12%);
|
||||
color: #92400e;
|
||||
|
||||
&--posted {
|
||||
background: rgb(15 118 110 / 12%);
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
/* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand,
|
||||
the other is work forgotten, and a clerk who reads them the same way runs it twice. */
|
||||
&--running {
|
||||
background: rgb(22 84 143 / 12%);
|
||||
color: #16548f;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
background: rgb(163 42 28 / 12%);
|
||||
color: #a32a1c;
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
/* Says a run is still going after the clerk has left the button behind. */
|
||||
.bp-running {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgb(22 84 143 / 25%);
|
||||
border-left: 3px solid #16548f;
|
||||
border-radius: 6px;
|
||||
background: rgb(22 84 143 / 6%);
|
||||
color: #16548f;
|
||||
font-size: 13px;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +238,10 @@ $primary: #0f766e;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
|
||||
@@ -10,60 +10,70 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
/**
|
||||
* DDS Interest & Commission Batch Processing component.
|
||||
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
|
||||
*
|
||||
* Six batch job types, each following the same deliberate two-step
|
||||
* pattern from the legacy system: Calculate first (a dry-run preview),
|
||||
* then Post as a separate, explicit confirmation — so a wrong calculation
|
||||
* is never applied by accident. Modelled on the legacy Interest
|
||||
* Calculation screen this replaces.
|
||||
* Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has
|
||||
* been earned since the last one and parks it: interest against the customer's account, commission
|
||||
* against the agent. Neither is spendable yet. The matching *posting* job then moves that parked
|
||||
* figure where it belongs — into the customer's balance, or out to the agent.
|
||||
*
|
||||
* Backed by the DDS Batch Run API (`/dds/batchruns`). Only the three
|
||||
* "Calculation" job types can actually be generated - the three "Posting"
|
||||
* entries exist in the dropdown for parity with the legacy menu, but Post
|
||||
* always applies whatever run was most recently generated in this session,
|
||||
* regardless of which dropdown entry is currently selected (matching how
|
||||
* the Post button here is only ever gated on hasGenerated, not on jobType).
|
||||
* Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes
|
||||
* nothing else, so a figure that looks wrong is discarded by simply not posting it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-dds-batch-processing',
|
||||
templateUrl: './dds-batch-processing.component.html',
|
||||
styleUrls: ['./dds-batch-processing.component.scss'],
|
||||
imports: [...STANDALONE_SHARED_IMPORTS]
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
// The reason a run failed is too long to sit in the column, and too useful to leave out.
|
||||
MatTooltip
|
||||
],
|
||||
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
|
||||
// a @Component's providers reach only this component, so no other screen's datepicker changes.
|
||||
providers: [
|
||||
{ provide: DateAdapter, useClass: DdsDateAdapter },
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
|
||||
]
|
||||
})
|
||||
export class DdsBatchProcessingComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private router = inject(Router);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private alertService = inject(AlertService);
|
||||
private dialog = inject(MatDialog);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
private static readonly JOB_TYPE_CODES: Record<string, string> = {
|
||||
'Interest Calculation': 'INTEREST_CALCULATION',
|
||||
'OD Interest Calculation': 'OD_INTEREST_CALCULATION',
|
||||
'Commission Calculation': 'COMMISSION_CALCULATION'
|
||||
};
|
||||
|
||||
private currentBatchRunId: number | null = null;
|
||||
|
||||
offices: any[] = [];
|
||||
selectedOfficeNames = new Set<string>();
|
||||
|
||||
jobTypes = [
|
||||
'Interest Calculation',
|
||||
'Interest Posting',
|
||||
'OD Interest Calculation',
|
||||
'OD Interest Posting',
|
||||
'Commission Calculation',
|
||||
'Commission Posting'
|
||||
/**
|
||||
* The menu as the legacy system lists it, paired with what the API calls each job. Ordered as
|
||||
* calculate-then-post within each pair, matching the numbering on the legacy screen.
|
||||
*/
|
||||
jobTypes: Array<{ label: string; code: string }> = [
|
||||
{ label: 'Interest Calculation', code: 'INTEREST_CALCULATION' },
|
||||
{ label: 'Interest Posting', code: 'INTEREST_POSTING' },
|
||||
{ label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' },
|
||||
{ label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' },
|
||||
{ label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' },
|
||||
{ label: 'Commission Posting', code: 'COMMISSION_POSTING' },
|
||||
// Last, and on its own: the others move money onto accounts that stay open, while this one pays
|
||||
// an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the
|
||||
// second half of one.
|
||||
{ label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' }
|
||||
];
|
||||
|
||||
branchScopes = [
|
||||
@@ -71,12 +81,23 @@ export class DdsBatchProcessingComponent implements OnInit {
|
||||
'Selected'
|
||||
];
|
||||
|
||||
hasGenerated = false;
|
||||
previewRows: Array<{ agent: string; party: string; amount: number }> = [];
|
||||
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
offices: any[] = [];
|
||||
selectedOfficeNames = new Set<string>();
|
||||
|
||||
/** The run currently previewed on screen, or null once it has been posted or discarded. */
|
||||
generatedRun: any = null;
|
||||
previewRows: any[] = [];
|
||||
|
||||
recentRuns: any[] = [];
|
||||
|
||||
batchForm: UntypedFormGroup = this.formBuilder.group({
|
||||
jobType: [
|
||||
'Interest Calculation',
|
||||
'INTEREST_CALCULATION',
|
||||
Validators.required
|
||||
],
|
||||
branchScope: [
|
||||
@@ -105,18 +126,56 @@ export class DdsBatchProcessingComponent implements OnInit {
|
||||
]
|
||||
});
|
||||
|
||||
get jobType(): string {
|
||||
return this.batchForm.get('jobType').value;
|
||||
}
|
||||
|
||||
get jobTypeLabel(): string {
|
||||
return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType;
|
||||
}
|
||||
|
||||
/** Commission is per agent, so the party range does not narrow it. */
|
||||
get isCommissionJob(): boolean {
|
||||
return this.jobType.startsWith('COMMISSION');
|
||||
}
|
||||
|
||||
get isPostingJob(): boolean {
|
||||
return (this.batchForm.get('jobType').value as string).includes('Posting');
|
||||
return this.jobType.endsWith('_POSTING');
|
||||
}
|
||||
|
||||
get isBranchSelected(): boolean {
|
||||
return this.batchForm.get('branchScope')?.value === 'Selected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a commission calculation has a breakdown worth showing: it is the one job where the figure
|
||||
* applied is not the figure worked out, because TDS and the standing deductions come off it.
|
||||
*/
|
||||
get showsCommissionBreakdown(): boolean {
|
||||
return this.jobType === 'COMMISSION_CALCULATION';
|
||||
}
|
||||
|
||||
/** What Generate found, in words — the same figure the Post button is about to apply. */
|
||||
get previewTotal(): number {
|
||||
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
||||
this.offices = offices || [];
|
||||
});
|
||||
this.loadRecentRuns();
|
||||
}
|
||||
|
||||
money(value: any): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—';
|
||||
}
|
||||
return DdsBatchProcessingComponent.MONEY.format(Number(value));
|
||||
}
|
||||
|
||||
labelOf(code: string): string {
|
||||
return this.jobTypes.find((type) => type.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
toggleOffice(officeName: string): void {
|
||||
@@ -127,59 +186,202 @@ export class DdsBatchProcessingComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What every past run did and whether it was applied. Without this a generated-but-unposted run is
|
||||
* invisible the moment the page is left, and there is no way to tell whether a month's interest
|
||||
* was ever actually posted.
|
||||
*/
|
||||
private loadRecentRuns(): void {
|
||||
this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => {
|
||||
this.recentRuns = runs || [];
|
||||
});
|
||||
}
|
||||
|
||||
generate(): void {
|
||||
const raw = this.batchForm.getRawValue();
|
||||
const jobType = DdsBatchProcessingComponent.JOB_TYPE_CODES[raw.jobType];
|
||||
|
||||
if (!jobType) {
|
||||
// A "Posting" entry was selected - there is nothing to calculate for
|
||||
// it, only to post (see class-level note on how Post is wired).
|
||||
this.hasGenerated = true;
|
||||
this.previewRows = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
const payload: any = {
|
||||
jobType,
|
||||
jobType: raw.jobType,
|
||||
branchScope: raw.branchScope,
|
||||
jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat),
|
||||
agentFrom: raw.agentFrom,
|
||||
agentTo: raw.agentTo,
|
||||
partyFrom: raw.partyFrom,
|
||||
partyTo: raw.partyTo,
|
||||
dateFormat,
|
||||
locale: this.settingsService.language.code
|
||||
};
|
||||
|
||||
// The party range narrows an interest job to particular accounts; commission is worked out per
|
||||
// agent on their whole collection, so sending a range there would only imply a filter that has
|
||||
// no effect.
|
||||
if (!this.isCommissionJob) {
|
||||
payload.partyFrom = raw.partyFrom;
|
||||
payload.partyTo = raw.partyTo;
|
||||
}
|
||||
|
||||
if (raw.branchScope === 'Selected') {
|
||||
payload.officeNames = Array.from(this.selectedOfficeNames);
|
||||
}
|
||||
|
||||
this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => {
|
||||
this.currentBatchRunId = result.resourceId;
|
||||
this.organizationService.getDdsBatchRun(this.currentBatchRunId).subscribe((batchRun: any) => {
|
||||
this.previewRows = (batchRun.rows || []).map((row: any) => ({
|
||||
agent: row.agentCode,
|
||||
party: row.partyCode || '—',
|
||||
amount: row.amount
|
||||
}));
|
||||
this.hasGenerated = true;
|
||||
this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => {
|
||||
this.generatedRun = batchRun;
|
||||
this.previewRows = batchRun.rows || [];
|
||||
|
||||
// A run that found nothing is the normal outcome of a period already processed, not a
|
||||
// failure — but it has to say so, or an empty table reads as the screen being broken.
|
||||
if (this.previewRows.length === 0) {
|
||||
this.alertService.alert({
|
||||
type: 'Information',
|
||||
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
|
||||
});
|
||||
} else {
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
|
||||
});
|
||||
}
|
||||
|
||||
this.loadRecentRuns();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
post(): void {
|
||||
if (!this.currentBatchRunId) {
|
||||
if (!this.generatedRun || this.previewRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.organizationService.postDdsBatchRun(this.currentBatchRunId).subscribe(() => {
|
||||
this.currentBatchRunId = null;
|
||||
this.hasGenerated = false;
|
||||
|
||||
const count = this.previewRows.length;
|
||||
const total = this.money(this.previewTotal);
|
||||
const isPosting = this.isPostingJob;
|
||||
|
||||
// Post is the step that moves money, and it cannot be undone from this screen — so it names
|
||||
// exactly what is about to change rather than asking a generic "are you sure".
|
||||
this.dialog
|
||||
.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
type: 'warn',
|
||||
heading: `Post ${this.jobTypeLabel}?`,
|
||||
dialogContext:
|
||||
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. `
|
||||
+ (isPosting
|
||||
? 'This credits the customers or pays the agents, and appears on the ledger. '
|
||||
: 'This records what has been earned against each account. ')
|
||||
+ 'It cannot be undone from this screen.'
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((response: any) => {
|
||||
if (response?.confirm) {
|
||||
this.applyRun(count, total);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the run, and says what actually happened rather than assuming it finished.
|
||||
*
|
||||
* A crediting run costs what a savings deposit costs on every account it touches, so the server
|
||||
* takes it off the request and does it in the background - the reply comes back in a moment while
|
||||
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
|
||||
* seconds after it began, and they would go looking for credits that were still being written.
|
||||
*
|
||||
* So the run is followed until it settles. A calculation run is done by the time the reply lands
|
||||
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
|
||||
* until the server marks it posted - or failed, with the reason the server recorded.
|
||||
*/
|
||||
private applyRun(count: number, total: string): void {
|
||||
const label = this.jobTypeLabel;
|
||||
const runId = this.generatedRun.id;
|
||||
const crediting = this.isPostingJob;
|
||||
|
||||
this.organizationService.postDdsBatchRun(runId).subscribe(() => {
|
||||
this.generatedRun = null;
|
||||
this.previewRows = [];
|
||||
this.loadRecentRuns();
|
||||
|
||||
if (!crediting) {
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.postingRunId = runId;
|
||||
this.alertService.alert({
|
||||
type: 'Posting',
|
||||
message: `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. `
|
||||
+ 'It continues in the background; this list will update when it is done.'
|
||||
});
|
||||
this.followRun(runId, label);
|
||||
});
|
||||
}
|
||||
|
||||
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
|
||||
postingRunId: number | null = null;
|
||||
|
||||
/**
|
||||
* Watches a background run until the server marks it settled.
|
||||
*
|
||||
* Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
|
||||
* costs nothing when the answer is one row. It gives up after a while rather than asking forever -
|
||||
* the run is still on the list either way, and a clerk can read its status there.
|
||||
*/
|
||||
private followRun(runId: number, label: string): void {
|
||||
let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
|
||||
|
||||
const check = () => {
|
||||
this.organizationService.getDdsBatchRuns().subscribe({
|
||||
next: (runs: any[]) => {
|
||||
const run = (runs || []).find((each: any) => each.id === runId);
|
||||
const status = run?.status;
|
||||
|
||||
if (status === 'POSTED' || status === 'FAILED') {
|
||||
this.postingRunId = null;
|
||||
this.loadRecentRuns();
|
||||
this.alertService.alert(
|
||||
status === 'POSTED'
|
||||
? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
|
||||
: { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (--checksLeft > 0) {
|
||||
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
|
||||
} else {
|
||||
// Still going after the wait. Left on the list rather than reported either way - saying
|
||||
// "failed" for a run that is merely long would be worse than saying nothing.
|
||||
this.postingRunId = null;
|
||||
this.loadRecentRuns();
|
||||
}
|
||||
},
|
||||
// A failed status check says nothing about the run itself, which carries on regardless.
|
||||
error: () => {
|
||||
if (--checksLeft > 0) {
|
||||
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
|
||||
} else {
|
||||
this.postingRunId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
|
||||
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
|
||||
private static readonly MAX_STATUS_CHECKS = 300;
|
||||
|
||||
/** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */
|
||||
discard(): void {
|
||||
this.generatedRun = null;
|
||||
this.previewRows = [];
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.router.navigate(['/organization']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { NativeDateAdapter, MatDateFormats } from '@angular/material/core';
|
||||
|
||||
/**
|
||||
* Day-first dates for the DDS batch screen.
|
||||
*
|
||||
* Angular Material's stock adapter shows and reads dates the American way — 12/31/2026 — and its
|
||||
* parser hands typed input to `Date.parse`, which cannot read a day-first date at all: typing
|
||||
* 31/12/2026 yields no date, so the field silently empties and reports itself invalid.
|
||||
*
|
||||
* On a screen where the date decides which period's interest is calculated, both halves matter: a
|
||||
* clerk who reads 12/31 as the 12th of some month picks the wrong period, and one who types the date
|
||||
* they are used to gets an error with nothing to explain it.
|
||||
*
|
||||
* Scoped to this component rather than provided application-wide. Every other datepicker in the app
|
||||
* has the same behaviour, and changing it globally would alter screens outside DDS.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DdsDateAdapter extends NativeDateAdapter {
|
||||
/** Matches d/m/yyyy, with or without leading zeros, on / . or - separators. */
|
||||
private static readonly DAY_FIRST = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/;
|
||||
|
||||
override parse(value: any): Date | null {
|
||||
if (typeof value === 'string') {
|
||||
const match = DdsDateAdapter.DAY_FIRST.exec(value.trim());
|
||||
if (match) {
|
||||
const day = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const year = Number(match[3]);
|
||||
|
||||
const parsed = new Date(year, month - 1, day);
|
||||
// JavaScript rolls an out-of-range day into the next month rather than rejecting it, so
|
||||
// 31/02/2026 would quietly become the 3rd of March. Reading the parts back catches that.
|
||||
const isRealDate =
|
||||
parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day;
|
||||
return isRealDate ? parsed : null;
|
||||
}
|
||||
}
|
||||
return super.parse(value);
|
||||
}
|
||||
|
||||
override format(date: Date, displayFormat: any): string {
|
||||
if (displayFormat === 'input') {
|
||||
return `${this.pad(date.getDate())}/${this.pad(date.getMonth() + 1)}/${date.getFullYear()}`;
|
||||
}
|
||||
return super.format(date, displayFormat);
|
||||
}
|
||||
|
||||
private pad(value: number): string {
|
||||
return value < 10 ? `0${value}` : `${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the datepicker to render the field itself day-first; the calendar keeps its usual labels. */
|
||||
export const DDS_DATE_FORMATS: MatDateFormats = {
|
||||
parse: { dateInput: 'input' },
|
||||
display: {
|
||||
dateInput: 'input',
|
||||
monthYearLabel: { year: 'numeric', month: 'short' },
|
||||
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
monthYearA11yLabel: { year: 'numeric', month: 'long' }
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { OrganizationComponent } from './organization.component';
|
||||
import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/loan-provisioning-criteria.component';
|
||||
import { OfficesComponent } from './offices/offices.component';
|
||||
import { AgentMasterComponent } from './agent-master/agent-master.component';
|
||||
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
|
||||
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component';
|
||||
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
|
||||
import { EmployeesComponent } from './employees/employees.component';
|
||||
@@ -242,8 +243,23 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'agent-master',
|
||||
component: AgentMasterComponent,
|
||||
data: { title: 'Agent Master', breadcrumb: 'Agent Master' }
|
||||
data: { title: 'Agent Master', breadcrumb: 'Agent Master' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: AgentMasterComponent
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
component: CreateAgentComponent,
|
||||
data: { title: 'Create Agent', breadcrumb: 'Create' }
|
||||
},
|
||||
{
|
||||
path: ':agentId/edit',
|
||||
component: CreateAgentComponent,
|
||||
data: { title: 'Edit Agent', breadcrumb: 'Edit', routeParamBreadcrumb: false }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'commission-slab-master',
|
||||
|
||||
@@ -272,6 +272,17 @@ export class OrganizationService {
|
||||
return this.http.get('/dds/parties');
|
||||
}
|
||||
|
||||
/**
|
||||
* The parties a given agent collects for — what the daily transaction screen works from, since
|
||||
* it handles one agent's round at a time.
|
||||
*
|
||||
* @param {string | number} agentId Agent ID.
|
||||
* @returns {Observable<any>} That agent's DDS Parties.
|
||||
*/
|
||||
getPartiesOfAgent(agentId: string | number): Observable<any> {
|
||||
return this.http.get('/dds/parties', { params: new HttpParams().set('agentId', String(agentId)) });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number} partyId Party ID.
|
||||
* @returns {Observable<any>} DDS Party.
|
||||
@@ -306,6 +317,65 @@ export class OrganizationService {
|
||||
return this.http.post(`/dds/parties/${partyId}?command=close`, closeCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* The DDS accounts whose term has run out and which are still open - the branch's list of closures
|
||||
* waiting to be done. Nothing closes itself: a matured account is money the bank owes the customer,
|
||||
* and where it goes has to be agreed with them.
|
||||
*
|
||||
* @returns {Observable<any>} Accounts due for closure, oldest maturity first.
|
||||
*/
|
||||
getPartiesDueForClosure(): Observable<any> {
|
||||
return this.http.get('/dds/parties/dueforclosure');
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's own accounts a DDS maturity can be paid into - active savings accounts of theirs,
|
||||
* with fixed and recurring deposits and their other DDS scheme accounts left out.
|
||||
*
|
||||
* The server applies the same conditions the payout itself does, so this list can never offer a
|
||||
* choice that is then refused on save. Filtering here instead meant the two drifted apart: the
|
||||
* accounts on offer once included a scheme account of a party that had already closed.
|
||||
*
|
||||
* @param {string | number} clientId Client ID.
|
||||
* @returns {Observable<any>} id, accountNo, productName and balance for each.
|
||||
*/
|
||||
getCreditableAccounts(clientId: string | number): Observable<any> {
|
||||
return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured DDS account out and closes it. What is paid is what the account holds, not the
|
||||
* figure quoted at opening.
|
||||
*
|
||||
* @param {string | number} partyId Party ID.
|
||||
* @param {any} payoutCommand { toSavingsAccountNo } - another savings account of the same customer
|
||||
* to move the money into. Omit it when they have none here: the money is then parked in the
|
||||
* matured-deposits payable account for the branch to send on to their account at another bank.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
payOutParty(partyId: string | number, payoutCommand: any): Observable<any> {
|
||||
return this.http.post(`/dds/parties/${partyId}?command=payout`, payoutCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a DDS account before its term is up, and pays out what is left - one act, unlike a
|
||||
* maturity payout, which leaves the account open for the clerk to close separately.
|
||||
*
|
||||
* @param {string | number} partyId Party ID.
|
||||
* @param {any} closeCommand { shortfallBorneBy, closeDate, dateFormat, locale, toSavingsAccountNo? }.
|
||||
* closeDate, dateFormat and locale go up exactly as they do on an ordinary close.
|
||||
* shortfallBorneBy is mandatory
|
||||
* and is CUSTOMER (their interest is recalculated at the scheme's reduced rate and the
|
||||
* difference taken back) or AGENT (the customer keeps a full month's interest and the agent
|
||||
* funds the days left in it). The agent is charged a share of their commission either way.
|
||||
* toSavingsAccountNo works as it does for a payout. The lock-in, the rate reduction and the
|
||||
* agent's share come from the scheme and are applied by the server.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
prematureCloseParty(partyId: string | number, closeCommand: any): Observable<any> {
|
||||
return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post.
|
||||
* @returns {Observable<any>}
|
||||
@@ -314,6 +384,39 @@ export class OrganizationService {
|
||||
return this.http.post('/dds/partytransactions', transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an agent wrote down on their round for a date, with what it adds up to.
|
||||
*
|
||||
* The counter reads it to count cash against; the agent's own screen shows it back as the record
|
||||
* of their round. One call for both, so the two can never disagree about the same day.
|
||||
*/
|
||||
getAgentCollectionDay(agentId: any, entryDate: string): Observable<any> {
|
||||
return this.http.get(`/dds/collectionentries?agentId=${agentId}&entryDate=${entryDate}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an agent's outstanding entries for a date into real credits - all of them, together.
|
||||
*
|
||||
* No amount is sent. What settles is what the entries say, to the rupee: a figure typed at the
|
||||
* counter would be a second opinion about a total the entries already give.
|
||||
*/
|
||||
settleAgentCollections(settlement: any): Observable<any> {
|
||||
return this.http.post('/dds/collectionentries/settle', settlement);
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's DDS transactions. The daily entry screen uses this to show what has already been
|
||||
* keyed for the agent today, so the same collection book is not entered twice.
|
||||
*
|
||||
* @param {string | number} agentId Agent ID.
|
||||
* @returns {Observable<any>} That agent's transactions.
|
||||
*/
|
||||
getPartyTransactionsOfAgent(agentId: string | number): Observable<any> {
|
||||
return this.http.get('/dds/partytransactions', {
|
||||
params: new HttpParams().set('agentId', String(agentId))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} DDS Commission Slabs data
|
||||
*/
|
||||
@@ -329,6 +432,18 @@ export class OrganizationService {
|
||||
return this.http.post('/dds/commissionslabs', { slabs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects one saved commission slab. The backend re-checks the corrected range against the other
|
||||
* slabs of its type and refuses an overlap.
|
||||
*
|
||||
* @param {string | number} slabId Commission slab ID.
|
||||
* @param {any} slab The corrected slab.
|
||||
* @returns {Observable<any>} Command result.
|
||||
*/
|
||||
updateCommissionSlab(slabId: string | number, slab: any): Observable<any> {
|
||||
return this.http.put(`/dds/commissionslabs/${slabId}`, slab);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} batch Batch run filters (jobType, branchScope, jobDate, agentFrom/To, partyFrom/To) to generate a preview for.
|
||||
* @returns {Observable<any>}
|
||||
@@ -345,6 +460,24 @@ export class OrganizationService {
|
||||
return this.http.get(`/dds/batchruns/${batchRunId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} Every batch run, most recent first — who ran what, and whether it was posted.
|
||||
*/
|
||||
getDdsBatchRuns(): Observable<any> {
|
||||
return this.http.get('/dds/batchruns');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
|
||||
* collected cash, or are owed commission — distinct from deactivating, which is never refused.
|
||||
*
|
||||
* @param {string | number} agentId Agent ID.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
closeAgent(agentId: string | number): Observable<any> {
|
||||
return this.http.post(`/dds/agents/${agentId}`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number} batchRunId Batch run ID to post - applies exactly the rows it previewed.
|
||||
* @returns {Observable<any>}
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="rdp-container">
|
||||
<div class="rdp-header">
|
||||
<i class="fas fa-calendar-day rdp-header__icon"></i>
|
||||
<h2 class="rdp-header__title">{{ 'labels.heading.Create Daily Deposit Scheme Product' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Currency/Terms/Settings/Charges/Accounting still run underneath (the account-creation
|
||||
engine needs them) but are pre-filled with fixed defaults in ngAfterViewInit() rather than
|
||||
shown - the legacy Account Type Creation screen never asked for any of this either. Kept
|
||||
mounted (just hidden) so their forms exist for the "product" payload to read from. -->
|
||||
<div hidden>
|
||||
<mifosx-daily-deposit-scheme-product-currency-step
|
||||
[DailyDepositSchemeProductsTemplate]="productsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-currency-step>
|
||||
<mifosx-daily-deposit-scheme-product-terms-step
|
||||
[DailyDepositSchemeProductsTemplate]="productsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-terms-step>
|
||||
<mifosx-daily-deposit-scheme-product-settings-step
|
||||
[DailyDepositSchemeProductsTemplate]="productsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-settings-step>
|
||||
<mifosx-daily-deposit-scheme-product-charges-step
|
||||
[DailyDepositSchemeProductsTemplate]="productsTemplate"
|
||||
[currencyCode]="currencyForm.get('currencyCode')"
|
||||
></mifosx-daily-deposit-scheme-product-charges-step>
|
||||
<mifosx-daily-deposit-scheme-product-accounting-step
|
||||
[DailyDepositSchemeProductsTemplate]="productsTemplate"
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[DailyDepositSchemeProductFormValid]="productFormValid"
|
||||
[isDormancyTrackingActive]="currencyForm.get('dormancyAlert')"
|
||||
></mifosx-daily-deposit-scheme-product-accounting-step>
|
||||
</div>
|
||||
|
||||
<mat-tab-group
|
||||
class="rdp-tabs"
|
||||
animationDuration="200ms"
|
||||
[preserveContent]="true"
|
||||
[(selectedIndex)]="selectedTabIndex"
|
||||
>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-info-circle rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.DETAILS' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-details-step
|
||||
[DailyDepositSchemeProductsTemplate]="productsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-details-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-chart-area rdp-tab-icon"></i>
|
||||
CBS Base Chart
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
@if (activeBaseVersion) {
|
||||
<div class="base-rate-card">
|
||||
<div class="base-rate-header">
|
||||
<div>
|
||||
<h3 class="base-rate-title">{{ activeBaseVersion.versionLabel }}</h3>
|
||||
<p class="base-rate-meta">
|
||||
Active CBS base chart
|
||||
<span>Effective from {{ activeBaseVersion.effectiveFromDate }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="base-rate-badge">Active</span>
|
||||
</div>
|
||||
|
||||
@if (activeBaseVersion.remarks) {
|
||||
<p class="base-rate-remarks">{{ activeBaseVersion.remarks }}</p>
|
||||
}
|
||||
|
||||
<table class="base-rate-table" mat-table [dataSource]="activeBaseVersion.slabs">
|
||||
<ng-container matColumnDef="srNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Sr.</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Particular</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.description }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fromPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.fromPeriod }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="toPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? 'N/A' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="periodTypeLabel">
|
||||
<th mat-header-cell *matHeaderCellDef>Period Type</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.periodTypeLabel }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="annualInterestRate">
|
||||
<th mat-header-cell *matHeaderCellDef>Annual Rate (%)</th>
|
||||
<td mat-cell *matCellDef="let slab">
|
||||
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Loading CBS base chart...</p>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<div class="rdp-action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="rdp-action-bar__cancel">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<div class="rdp-action-bar__right">
|
||||
<button mat-raised-button color="primary" [disabled]="!productFormValid" (click)="submit()">
|
||||
<i class="fas fa-check" style="margin-right: 8px"></i>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
|
||||
.rdp-container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.rdp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__icon {
|
||||
font-size: 20px;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-tabs {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 100px;
|
||||
padding: 0 12px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-tab-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rdp-tab-content {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.base-rate-card {
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.base-rate-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.base-rate-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.base-rate-meta {
|
||||
margin: 6px 0 0;
|
||||
color: #5b6b7f;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.base-rate-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.base-rate-remarks {
|
||||
margin: 0 0 12px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.base-rate-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rdp-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&__cancel {
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { AfterViewInit, Component, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
/** Custom Components — Daily Deposit Scheme's own stepper, kept independent
|
||||
* of Recurring Deposit Products so future changes to either never cross over. */
|
||||
import { DailyDepositSchemeProductDetailsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component';
|
||||
import { DailyDepositSchemeProductCurrencyStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component';
|
||||
import { DailyDepositSchemeProductTermsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component';
|
||||
import { DailyDepositSchemeProductSettingsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component';
|
||||
import { DailyDepositSchemeProductChargesStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component';
|
||||
import { DailyDepositSchemeProductAccountingStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Accounting } from 'app/core/utils/accounting';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-create-daily-deposit-scheme-product',
|
||||
templateUrl: './create-daily-deposit-scheme-product.component.html',
|
||||
styleUrls: ['./create-daily-deposit-scheme-product.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
DailyDepositSchemeProductDetailsStepComponent,
|
||||
DailyDepositSchemeProductCurrencyStepComponent,
|
||||
DailyDepositSchemeProductTermsStepComponent,
|
||||
DailyDepositSchemeProductSettingsStepComponent,
|
||||
DailyDepositSchemeProductChargesStepComponent,
|
||||
DailyDepositSchemeProductAccountingStepComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
]
|
||||
})
|
||||
export class CreateDailyDepositSchemeProductComponent implements AfterViewInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private productsService = inject(ProductsService);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private accounting = inject(Accounting);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
@ViewChild(DailyDepositSchemeProductDetailsStepComponent, { static: true })
|
||||
detailsStep: DailyDepositSchemeProductDetailsStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductCurrencyStepComponent, { static: true })
|
||||
currencyStep: DailyDepositSchemeProductCurrencyStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductTermsStepComponent, { static: true })
|
||||
termsStep: DailyDepositSchemeProductTermsStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductSettingsStepComponent, { static: true })
|
||||
settingsStep: DailyDepositSchemeProductSettingsStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductChargesStepComponent, { static: true })
|
||||
chargesStep: DailyDepositSchemeProductChargesStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductAccountingStepComponent, { static: true })
|
||||
accountingStep: DailyDepositSchemeProductAccountingStepComponent;
|
||||
|
||||
productsTemplate: any;
|
||||
accountingRuleData: string[] = [];
|
||||
selectedTabIndex = 0;
|
||||
rdInterestRates: any[] = [];
|
||||
readonly rdSlabColumns = [
|
||||
'srNo',
|
||||
'description',
|
||||
'fromPeriod',
|
||||
'toPeriod',
|
||||
'periodTypeLabel',
|
||||
'annualInterestRate'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { dailyDepositSchemeProductsTemplate: any }) => {
|
||||
this.productsTemplate = data.dailyDepositSchemeProductsTemplate;
|
||||
});
|
||||
this.accountingRuleData = this.accounting.getAccountingRulesForSavings();
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => {
|
||||
this.rdInterestRates = rates || [];
|
||||
},
|
||||
error: () => {
|
||||
this.rdInterestRates = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// The DDS product screen only shows the legacy Account Type Creation fields (Details tab) -
|
||||
// currency/terms/settings/charges/accounting still exist underneath (the account-creation
|
||||
// engine needs them), but they're filled in here with fixed sensible defaults rather than
|
||||
// asked of the user, since the legacy screen never asked for them either.
|
||||
const template = this.productsTemplate || {};
|
||||
|
||||
this.currencyForm.patchValue({
|
||||
currencyCode: this.currencyForm.value.currencyCode || 'INR',
|
||||
digitsAfterDecimal: this.currencyForm.value.digitsAfterDecimal ?? 2
|
||||
});
|
||||
|
||||
const firstOptionId = (options: any[]) => options?.[0]?.id ?? '';
|
||||
this.termsForm.patchValue({
|
||||
interestCompoundingPeriodType: firstOptionId(template.interestCompoundingPeriodTypeOptions),
|
||||
interestCalculationDaysInYearType: firstOptionId(template.interestCalculationDaysInYearTypeOptions),
|
||||
minDepositAmount: this.termsForm.value.minDepositAmount || 1,
|
||||
maxDepositAmount: this.termsForm.value.maxDepositAmount || 999999999
|
||||
});
|
||||
|
||||
// accountingRule 1 = None, matching the legacy screen (which never asked for GL mappings) -
|
||||
// this also clears every accounting-tab required-field validator, since those only apply
|
||||
// under Cash/Accrual accounting.
|
||||
this.accountingForm.patchValue({ accountingRule: 1 });
|
||||
|
||||
this.detailsForm.get('autoInterestPost')?.valueChanges.subscribe(() => this.syncAutoInterestPost());
|
||||
this.detailsForm.get('autoInterestPostWhen')?.valueChanges.subscribe(() => this.syncAutoInterestPost());
|
||||
this.syncAutoInterestPost();
|
||||
}
|
||||
|
||||
/** Mirrors the simple "Auto Interest Post" / "Auto Interest Post When" / "Auto Interest Post
|
||||
* Day" fields onto the (hidden) terms-step fields that the account-creation engine actually
|
||||
* reads. */
|
||||
private syncAutoInterestPost(): void {
|
||||
const autoPost = this.detailsForm?.value.autoInterestPost ?? true;
|
||||
const postingPeriodType = this.detailsForm?.value.autoInterestPostWhen;
|
||||
const postingDay = this.detailsForm?.value.autoInterestPostDay;
|
||||
this.termsForm?.patchValue({
|
||||
autoPost,
|
||||
interestPostingPeriodType: postingPeriodType || this.termsForm.value.interestPostingPeriodType,
|
||||
frequencyFixDay: postingDay || this.termsForm.value.frequencyFixDay
|
||||
});
|
||||
}
|
||||
|
||||
get detailsForm() {
|
||||
return this.detailsStep?.DailyDepositSchemeProductDetailsForm;
|
||||
}
|
||||
|
||||
get currencyForm() {
|
||||
return this.currencyStep?.DailyDepositSchemeProductCurrencyForm;
|
||||
}
|
||||
|
||||
get termsForm() {
|
||||
return this.termsStep?.DailyDepositSchemeProductTermsForm;
|
||||
}
|
||||
|
||||
get settingsForm() {
|
||||
return this.settingsStep?.DailyDepositSchemeProductSettingsForm;
|
||||
}
|
||||
|
||||
get accountingForm() {
|
||||
return this.accountingStep?.DailyDepositSchemeProductAccountingForm;
|
||||
}
|
||||
|
||||
get productFormValid() {
|
||||
return !!this.detailsForm?.valid;
|
||||
}
|
||||
|
||||
get activeBaseVersion() {
|
||||
const chart =
|
||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ??
|
||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ??
|
||||
this.rdInterestRates[0];
|
||||
if (chart) {
|
||||
return {
|
||||
...chart,
|
||||
versionLabel: chart.versionLabel?.replace('FD', 'DDS'),
|
||||
remarks: chart.remarks?.replace('FD', 'DDS')
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get activeBaseSlabs() {
|
||||
// The base chart is borrowed from an FD product purely for its period/amount-range
|
||||
// *structure* - the account-creation engine needs some chart to exist. Its actual rate is
|
||||
// never used: every slab's annualInterestRate is overridden with the Details tab's own
|
||||
// Interest Rate field, so DDS never depends on FD's real interest figures.
|
||||
const rate = this.detailsForm?.value.interestRate;
|
||||
return (this.activeBaseVersion?.slabs || []).map((slab: any) => ({
|
||||
...slab,
|
||||
annualInterestRate: rate ?? slab.annualInterestRate
|
||||
}));
|
||||
}
|
||||
|
||||
get product() {
|
||||
// accountTypeGroup / autoInterestPost / autoInterestPostWhen / autoInterestPostDay are the
|
||||
// legacy Account Type Creation screen's own fields - not real product API parameters. The
|
||||
// auto-interest-post trio is already mirrored onto termsStep (autoPost /
|
||||
// interestPostingPeriodType / frequencyFixDay) via syncAutoInterestPost(); accountTypeGroup
|
||||
// is folded into the description here instead.
|
||||
const {
|
||||
accountTypeGroup,
|
||||
autoInterestPost,
|
||||
autoInterestPostWhen,
|
||||
autoInterestPostDay,
|
||||
interestRate,
|
||||
...detailsPayload
|
||||
} = this.detailsStep.DailyDepositSchemeProductDetails;
|
||||
const description = [
|
||||
accountTypeGroup,
|
||||
detailsPayload.description
|
||||
]
|
||||
.filter((part) => !!part)
|
||||
.join(' - ');
|
||||
|
||||
return {
|
||||
...detailsPayload,
|
||||
description,
|
||||
...this.currencyStep.DailyDepositSchemeProductCurrency,
|
||||
...this.termsStep.DailyDepositSchemeProductTerms,
|
||||
...this.settingsStep.DailyDepositSchemeProductSettings,
|
||||
...this.chargesStep.DailyDepositSchemeProductCharges,
|
||||
...this.accountingStep.DailyDepositSchemeProductAccounting,
|
||||
charts: this.productCharts,
|
||||
rdSlabs: this.activeBaseSlabs
|
||||
};
|
||||
}
|
||||
|
||||
get productCharts() {
|
||||
const activeBaseVersion = this.activeBaseVersion;
|
||||
if (!activeBaseVersion?.slabs?.length) {
|
||||
return [];
|
||||
}
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
return [
|
||||
{
|
||||
name: activeBaseVersion.versionLabel || 'CBS Base Chart',
|
||||
description: activeBaseVersion.remarks || activeBaseVersion.versionLabel || 'CBS base chart',
|
||||
fromDate:
|
||||
this.dateUtils.formatDate(activeBaseVersion.effectiveFromDate, dateFormat) ||
|
||||
activeBaseVersion.effectiveFromDate,
|
||||
...(activeBaseVersion.effectiveToDate
|
||||
? {
|
||||
endDate:
|
||||
this.dateUtils.formatDate(activeBaseVersion.effectiveToDate, dateFormat) ||
|
||||
activeBaseVersion.effectiveToDate
|
||||
}
|
||||
: {}),
|
||||
isPrimaryGroupingByAmount: false,
|
||||
locale: this.settingsService.language.code,
|
||||
dateFormat,
|
||||
chartSlabs: this.activeBaseSlabs.map((slab: any) => {
|
||||
const chartSlab: any = {
|
||||
periodType: slab.periodTypeEnum,
|
||||
fromPeriod: slab.fromPeriod,
|
||||
toPeriod: slab.toPeriod,
|
||||
annualInterestRate: slab.annualInterestRate,
|
||||
description: slab.description || '',
|
||||
incentives: [] as any[]
|
||||
};
|
||||
|
||||
if (slab.amountRangeFrom !== null && slab.amountRangeFrom !== undefined && slab.amountRangeFrom !== '') {
|
||||
chartSlab.amountRangeFrom = slab.amountRangeFrom;
|
||||
}
|
||||
|
||||
if (slab.amountRangeTo !== null && slab.amountRangeTo !== undefined && slab.amountRangeTo !== '') {
|
||||
chartSlab.amountRangeTo = slab.amountRangeTo;
|
||||
}
|
||||
|
||||
return chartSlab;
|
||||
})
|
||||
}
|
||||
] as any[];
|
||||
}
|
||||
|
||||
submit() {
|
||||
const product: any = this.pruneEmptyValues({
|
||||
...this.product,
|
||||
charts: this.productCharts,
|
||||
charges: (this.product.charges ?? []).map((charge: any) => ({ id: charge.id })),
|
||||
locale: this.settingsService.language.code,
|
||||
productCategory: 'DDS'
|
||||
});
|
||||
if (!product.description) {
|
||||
product.description = '';
|
||||
}
|
||||
delete product.advancedAccountingRules;
|
||||
this.productsService.createRecurringDepositProduct(product).subscribe((response: any) => {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
response.resourceId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private pruneEmptyValues<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.pruneEmptyValues(item)).filter((item) => item !== undefined) as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, any>).reduce(
|
||||
(acc, [
|
||||
key,
|
||||
currentValue
|
||||
]) => {
|
||||
const cleanedValue = this.pruneEmptyValues(currentValue);
|
||||
if (cleanedValue === undefined) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[key]: cleanedValue
|
||||
};
|
||||
},
|
||||
{} as Record<string, any>
|
||||
) as T;
|
||||
}
|
||||
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProductsService } from '../../products.service';
|
||||
|
||||
/**
|
||||
* Daily Deposit Scheme Products template resolver.
|
||||
*
|
||||
* Reuses the Recurring Deposit product template — a Daily Deposit Scheme
|
||||
* product is created through the same underlying product-creation engine.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DailyDepositSchemeProductsTemplateResolver {
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
/**
|
||||
* Returns the Daily Deposit Scheme products template.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(): Observable<any> {
|
||||
return this.productsService.getRecurringDepositProductsTemplate();
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="DailyDepositSchemeProductAccountingForm">
|
||||
<div class="rdpa-section">
|
||||
<div class="rdpa-section__header">
|
||||
<span class="rdpa-section__icon">
|
||||
<i class="fas fa-book"></i>
|
||||
</span>
|
||||
<h3>Accounting</h3>
|
||||
</div>
|
||||
|
||||
<div class="rdpa-section__body">
|
||||
<div class="rdpa-group">
|
||||
<div class="rdpa-group__header">
|
||||
<h4>Accounting Rule</h4>
|
||||
</div>
|
||||
<div class="rdpa-radio-group">
|
||||
<mat-radio-button [value]="2" [checked]="true" [disabled]="true">Cash</mat-radio-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isCashOrAccrualAccounting()) {
|
||||
@for (section of accountingSectionConfigs; track section.key) {
|
||||
<div class="rdpa-group">
|
||||
<div class="rdpa-group__header">
|
||||
<h4>{{ section.title }}</h4>
|
||||
<button type="button" class="rdpa-add-btn" (click)="addOtherGlMapping(section.otherMappingsControlName)">
|
||||
+ Add Other
|
||||
</button>
|
||||
</div>
|
||||
<div class="rdpa-list">
|
||||
@for (row of section.rows; track row.key) {
|
||||
@if (
|
||||
(!row.accrualOnly || isAccrualAccounting()) && (!row.dormancyOnly || isDormancyTrackingEnabled())
|
||||
) {
|
||||
<div class="rdpa-row">
|
||||
<div class="rdpa-row__meta">
|
||||
<span
|
||||
class="rdpa-pill"
|
||||
[class.rdpa-pill--green]="section.pillClass === 'green'"
|
||||
[class.rdpa-pill--amber]="section.pillClass === 'amber'"
|
||||
[class.rdpa-pill--blue]="section.pillClass === 'blue'"
|
||||
>
|
||||
{{ section.pillLabel }}
|
||||
</span>
|
||||
<span class="rdpa-row__label">{{ row.label }}</span>
|
||||
</div>
|
||||
<mifosx-gl-account-selector
|
||||
class="rdpa-row__input"
|
||||
[inputFormControl]="DailyDepositSchemeProductAccountingForm.get(row.controlName)"
|
||||
[glAccountList]="row.options"
|
||||
[required]="row.required"
|
||||
[inputLabel]="row.inputLabel"
|
||||
></mifosx-gl-account-selector>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rdpa-list" [formArrayName]="section.otherMappingsControlName">
|
||||
@for (
|
||||
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
|
||||
track $index;
|
||||
let i = $index
|
||||
) {
|
||||
<div class="rdpa-other-row-wrap">
|
||||
<div class="rdpa-row rdpa-row--other" [formGroupName]="i">
|
||||
<div class="rdpa-row__meta rdpa-row__meta--other">
|
||||
<span
|
||||
class="rdpa-pill"
|
||||
[class.rdpa-pill--green]="section.pillClass === 'green'"
|
||||
[class.rdpa-pill--amber]="section.pillClass === 'amber'"
|
||||
[class.rdpa-pill--blue]="section.pillClass === 'blue'"
|
||||
>
|
||||
{{ section.pillLabel }}
|
||||
</span>
|
||||
<input
|
||||
class="rdpa-row__name rdpa-row__name--other-account"
|
||||
formControlName="accountName"
|
||||
placeholder="Enter account name..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rdpa-row__actions rdpa-row__actions--other">
|
||||
<mat-form-field class="rdpa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>GL Code</mat-label>
|
||||
<mat-select
|
||||
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
|
||||
>
|
||||
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
|
||||
<mat-option [value]="glAccount.id">
|
||||
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rdpa-remove-btn rdpa-remove-btn--side"
|
||||
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-303
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$gray-50: #f8f9fa;
|
||||
$gray-200: #e8eaed;
|
||||
$gray-700: #334155;
|
||||
$gray-900: #202124;
|
||||
|
||||
.rdpa-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rdpa-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-section__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rdpa-section__body {
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
|
||||
.rdpa-group {
|
||||
& + & {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-add-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rdpa-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rdpa-list + .rdpa-list {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rdpa-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
@media (width <= 900px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-row:has(mifosx-gl-account-selector) {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rdpa-row--other {
|
||||
border-style: dashed;
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-other-row-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-row__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.rdpa-row__meta--other {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.rdpa-row__label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.rdpa-row__input {
|
||||
flex: 0 0 22rem;
|
||||
max-width: 22rem;
|
||||
min-width: 18rem;
|
||||
|
||||
@media (width <= 900px) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-row__input :is(mifosx-gl-account-selector) {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rdpa-row__input :is(mifosx-gl-account-selector) :is(.layout-column) {
|
||||
width: 100%;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.rdpa-row__input :is(mifosx-gl-account-selector) :is(mat-form-field) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdpa-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rdpa-row__actions--other {
|
||||
justify-content: flex-end;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-row__name {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
height: 40px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
|
||||
@media (width <= 900px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-row__name--other-account {
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.rdpa-row__other-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rdpa-row__other-field :is(mat-form-field) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdpa-row__other-field :is(.mat-mdc-form-field-required-marker) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rdpa-remove-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.rdpa-remove-btn--side {
|
||||
place-self: center end;
|
||||
margin-right: 4px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
justify-self: start;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpa-radio-group {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rdpa-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
|
||||
&--green {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.rdpa-row__meta {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rdpa-row__input {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
-423
@@ -1,423 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
UntypedFormArray,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormControl,
|
||||
UntypedFormGroup,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
|
||||
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||
import { Accounting } from 'app/core/utils/accounting';
|
||||
import { GLAccount } from 'app/shared/models/general.model';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
|
||||
|
||||
type AccountingRowConfig = {
|
||||
key: string;
|
||||
label: string;
|
||||
inputLabel: string;
|
||||
controlName: string;
|
||||
options: GLAccount[];
|
||||
required?: boolean;
|
||||
accrualOnly?: boolean;
|
||||
dormancyOnly?: boolean;
|
||||
};
|
||||
|
||||
type AccountingSectionConfig = {
|
||||
key: AccountingSectionKey;
|
||||
title: string;
|
||||
pillLabel: string;
|
||||
pillClass: 'green' | 'amber' | 'blue';
|
||||
rows: AccountingRowConfig[];
|
||||
otherMappingsControlName: string;
|
||||
otherAccountOptions: GLAccount[];
|
||||
};
|
||||
|
||||
type OtherGlMappingValue = {
|
||||
accountName: string;
|
||||
glAccountId: number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-accounting-step',
|
||||
templateUrl: './daily-deposit-scheme-product-accounting-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-accounting-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatRadioGroup,
|
||||
MatRadioButton,
|
||||
GlAccountSelectorComponent
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductAccountingStepComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private accounting = inject(Accounting);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
@Input() accountingRuleData: any;
|
||||
@Input() DailyDepositSchemeProductFormValid: boolean;
|
||||
@Input() isDormancyTrackingActive: any;
|
||||
|
||||
DailyDepositSchemeProductAccountingForm: UntypedFormGroup;
|
||||
assetAccountOptions: GLAccount[] = [];
|
||||
liabilityAccountOptions: GLAccount[] = [];
|
||||
expenseAccountOptions: GLAccount[] = [];
|
||||
incomeAccountOptions: GLAccount[] = [];
|
||||
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
||||
|
||||
constructor() {
|
||||
this.createDailyDepositSchemeProductAccountingForm();
|
||||
this.setConditionalControls();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const accountingMappings = this.DailyDepositSchemeProductsTemplate?.accountingMappings || {};
|
||||
const mappings = {
|
||||
...accountingMappings,
|
||||
otherGlMappingsMap: this.DailyDepositSchemeProductsTemplate?.otherGlMappingsMap
|
||||
};
|
||||
const accountingMappingOptions = this.DailyDepositSchemeProductsTemplate?.accountingMappingOptions || {};
|
||||
// Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves
|
||||
// the product's own current accounting rule when editing - previously this was hardcoded to 2
|
||||
// unconditionally, which silently forced every edited product onto Cash-based accounting (and
|
||||
// its required GL mappings) even for products that were and should remain "None".
|
||||
const accountingRuleId = this.DailyDepositSchemeProductsTemplate?.accountingRule?.id ?? 2;
|
||||
|
||||
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
|
||||
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
||||
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
||||
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
||||
|
||||
this.buildAccountingSectionConfigs();
|
||||
|
||||
this.DailyDepositSchemeProductAccountingForm.patchValue({ accountingRule: accountingRuleId });
|
||||
this.syncAccountingControls(accountingRuleId);
|
||||
this.patchAccountingValues(mappings);
|
||||
this.setDormancyControl();
|
||||
}
|
||||
|
||||
createDailyDepositSchemeProductAccountingForm() {
|
||||
this.DailyDepositSchemeProductAccountingForm = this.formBuilder.group({
|
||||
accountingRule: [2],
|
||||
savingsReferenceAccountId: [''],
|
||||
savingsControlAccountId: [''],
|
||||
transfersInSuspenseAccountId: [''],
|
||||
interestOnSavingsAccountId: [''],
|
||||
incomeFromFeeAccountId: [''],
|
||||
incomeFromPenaltyAccountId: [''],
|
||||
feesReceivableAccountId: [''],
|
||||
penaltiesReceivableAccountId: [''],
|
||||
interestPayableAccountId: [''],
|
||||
escheatLiabilityId: [''],
|
||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||
expensesOtherGlMappings: this.formBuilder.array([]),
|
||||
incomeOtherGlMappings: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
private setConditionalControls() {
|
||||
this.DailyDepositSchemeProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((rule: number) => {
|
||||
this.syncAccountingControls(rule);
|
||||
});
|
||||
}
|
||||
|
||||
private patchAccountingValues(mappings: any) {
|
||||
this.DailyDepositSchemeProductAccountingForm.patchValue({
|
||||
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
|
||||
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
|
||||
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
|
||||
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
|
||||
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
|
||||
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
|
||||
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
|
||||
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
|
||||
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
|
||||
escheatLiabilityId: mappings.escheatLiabilityAccount?.id || ''
|
||||
});
|
||||
|
||||
if (this.isCashOrAccrualAccounting()) {
|
||||
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
|
||||
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
||||
this.setOtherGlMappings(
|
||||
'liabilitiesOtherGlMappings',
|
||||
glMappings?.liabilities || [],
|
||||
this.liabilityAccountOptions
|
||||
);
|
||||
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
|
||||
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
|
||||
}
|
||||
}
|
||||
|
||||
private createOtherGlMappingGroup(otherGlMapping: Partial<OtherGlMappingValue> = {}) {
|
||||
return this.formBuilder.group({
|
||||
accountName: [
|
||||
otherGlMapping.accountName || '',
|
||||
Validators.required
|
||||
],
|
||||
glAccountId: [
|
||||
otherGlMapping.glAccountId || '',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
getOtherGlMappingsArray(controlName: string): UntypedFormArray {
|
||||
return this.DailyDepositSchemeProductAccountingForm.get(controlName) as UntypedFormArray;
|
||||
}
|
||||
|
||||
private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
|
||||
const arr = this.getOtherGlMappingsArray(controlName);
|
||||
arr.clear();
|
||||
(otherGlMappings || []).forEach((m) => {
|
||||
arr.push(
|
||||
this.createOtherGlMappingGroup({
|
||||
accountName: m.accountName,
|
||||
glAccountId: m.glAccountId || options.find((o) => o.glCode === m.glCode)?.id || null
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
addOtherGlMapping(controlName: string) {
|
||||
this.getOtherGlMappingsArray(controlName).push(this.createOtherGlMappingGroup());
|
||||
}
|
||||
|
||||
removeOtherGlMapping(controlName: string, index: number) {
|
||||
this.getOtherGlMappingsArray(controlName).removeAt(index);
|
||||
}
|
||||
|
||||
getOtherGlMappingControl(controlName: string, index: number, fieldName: string): UntypedFormControl {
|
||||
return this.getOtherGlMappingsArray(controlName).at(index).get(fieldName) as UntypedFormControl;
|
||||
}
|
||||
|
||||
private clearOtherGlMappings() {
|
||||
[
|
||||
'assetsOtherGlMappings',
|
||||
'liabilitiesOtherGlMappings',
|
||||
'expensesOtherGlMappings',
|
||||
'incomeOtherGlMappings'
|
||||
].forEach((c) => this.getOtherGlMappingsArray(c).clear());
|
||||
}
|
||||
|
||||
private getOtherGlMappings() {
|
||||
const map = (controlName: string, options: GLAccount[]) =>
|
||||
this.getOtherGlMappingsArray(controlName)
|
||||
.value.filter((item: OtherGlMappingValue) => !!item.accountName || !!item.glAccountId)
|
||||
.map((item: OtherGlMappingValue) => ({
|
||||
accountName: item.accountName,
|
||||
glCode: options.find((o) => o.id === item.glAccountId)?.glCode || ''
|
||||
}))
|
||||
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
|
||||
|
||||
const otherGlMappings = {
|
||||
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
|
||||
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
|
||||
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
|
||||
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
|
||||
};
|
||||
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
|
||||
}
|
||||
|
||||
private setDormancyControl() {
|
||||
this.isDormancyTrackingActive?.valueChanges?.subscribe(() => {
|
||||
this.syncAccountingControls(this.DailyDepositSchemeProductAccountingForm.value.accountingRule);
|
||||
});
|
||||
}
|
||||
|
||||
isDormancyTrackingEnabled(): boolean {
|
||||
return this.isDormancyTrackingActive?.value ?? this.isDormancyTrackingActive ?? false;
|
||||
}
|
||||
|
||||
private syncAccountingControls(accountingRule: number) {
|
||||
const setRequired = (name: string, required: boolean) => {
|
||||
const ctrl = this.DailyDepositSchemeProductAccountingForm.get(name);
|
||||
if (!ctrl) return;
|
||||
if (required) {
|
||||
ctrl.setValidators(Validators.required);
|
||||
} else {
|
||||
ctrl.clearValidators();
|
||||
}
|
||||
ctrl.updateValueAndValidity({ emitEvent: false });
|
||||
};
|
||||
|
||||
const cashOrAccrual = this.accounting.isCashOrAccrualAccountingRuleId(accountingRule);
|
||||
const accrualOnly = this.accounting.isAccrualAccountingRuleId(accountingRule);
|
||||
|
||||
setRequired('savingsReferenceAccountId', cashOrAccrual);
|
||||
setRequired('savingsControlAccountId', cashOrAccrual);
|
||||
setRequired('transfersInSuspenseAccountId', cashOrAccrual);
|
||||
setRequired('interestOnSavingsAccountId', cashOrAccrual);
|
||||
setRequired('incomeFromFeeAccountId', cashOrAccrual);
|
||||
setRequired('incomeFromPenaltyAccountId', cashOrAccrual);
|
||||
setRequired('feesReceivableAccountId', accrualOnly);
|
||||
setRequired('penaltiesReceivableAccountId', accrualOnly);
|
||||
setRequired('interestPayableAccountId', accrualOnly);
|
||||
setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
|
||||
|
||||
if (!cashOrAccrual) {
|
||||
this.clearOtherGlMappings();
|
||||
}
|
||||
}
|
||||
|
||||
buildAccountingSectionConfigs() {
|
||||
this.accountingSectionConfigs = [
|
||||
{
|
||||
key: 'assets',
|
||||
title: 'Assets',
|
||||
pillLabel: 'Asset',
|
||||
pillClass: 'green',
|
||||
rows: [
|
||||
{
|
||||
key: 'savingsReferenceAccountId',
|
||||
label: 'Savings Reference Account',
|
||||
inputLabel: 'Savings reference account',
|
||||
controlName: 'savingsReferenceAccountId',
|
||||
options: this.assetAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'feesReceivableAccountId',
|
||||
label: 'Fees Receivable Account',
|
||||
inputLabel: 'Fees receivable account',
|
||||
controlName: 'feesReceivableAccountId',
|
||||
options: this.assetAccountOptions,
|
||||
accrualOnly: true
|
||||
},
|
||||
{
|
||||
key: 'penaltiesReceivableAccountId',
|
||||
label: 'Penalties Receivable Account',
|
||||
inputLabel: 'Penalties receivable account',
|
||||
controlName: 'penaltiesReceivableAccountId',
|
||||
options: this.assetAccountOptions,
|
||||
accrualOnly: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'assetsOtherGlMappings',
|
||||
otherAccountOptions: this.assetAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'liabilities',
|
||||
title: 'Liabilities',
|
||||
pillLabel: 'Liability',
|
||||
pillClass: 'green',
|
||||
rows: [
|
||||
{
|
||||
key: 'savingsControlAccountId',
|
||||
label: 'Savings Control Account',
|
||||
inputLabel: 'Savings control account',
|
||||
controlName: 'savingsControlAccountId',
|
||||
options: this.liabilityAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'transfersInSuspenseAccountId',
|
||||
label: 'Transfers In Suspense Account',
|
||||
inputLabel: 'Transfers in suspense account',
|
||||
controlName: 'transfersInSuspenseAccountId',
|
||||
options: this.liabilityAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'interestPayableAccountId',
|
||||
label: 'Interest Payable Account',
|
||||
inputLabel: 'Interest payable account',
|
||||
controlName: 'interestPayableAccountId',
|
||||
options: this.liabilityAccountOptions,
|
||||
accrualOnly: true
|
||||
},
|
||||
{
|
||||
key: 'escheatLiabilityId',
|
||||
label: 'Escheat Liability Account',
|
||||
inputLabel: 'Escheat liability account',
|
||||
controlName: 'escheatLiabilityId',
|
||||
options: this.liabilityAccountOptions,
|
||||
dormancyOnly: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'liabilitiesOtherGlMappings',
|
||||
otherAccountOptions: this.liabilityAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'expenses',
|
||||
title: 'Expenses',
|
||||
pillLabel: 'Expense',
|
||||
pillClass: 'amber',
|
||||
rows: [
|
||||
{
|
||||
key: 'interestOnSavingsAccountId',
|
||||
label: 'Interest On Savings Account',
|
||||
inputLabel: 'Interest on savings account',
|
||||
controlName: 'interestOnSavingsAccountId',
|
||||
options: this.expenseAccountOptions,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'expensesOtherGlMappings',
|
||||
otherAccountOptions: this.expenseAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'income',
|
||||
title: 'Income',
|
||||
pillLabel: 'Income',
|
||||
pillClass: 'blue',
|
||||
rows: [
|
||||
{
|
||||
key: 'incomeFromFeeAccountId',
|
||||
label: 'Income From Fees',
|
||||
inputLabel: 'Income from fees',
|
||||
controlName: 'incomeFromFeeAccountId',
|
||||
options: this.incomeAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'incomeFromPenaltyAccountId',
|
||||
label: 'Income From Penalties',
|
||||
inputLabel: 'Income from penalties',
|
||||
controlName: 'incomeFromPenaltyAccountId',
|
||||
options: this.incomeAccountOptions,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'incomeOtherGlMappings',
|
||||
otherAccountOptions: this.incomeAccountOptions
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductAccounting() {
|
||||
const {
|
||||
assetsOtherGlMappings,
|
||||
liabilitiesOtherGlMappings,
|
||||
expensesOtherGlMappings,
|
||||
incomeOtherGlMappings,
|
||||
...accounting
|
||||
} = this.DailyDepositSchemeProductAccountingForm.value;
|
||||
const otherGlMappings = this.getOtherGlMappings();
|
||||
return { ...accounting, ...(otherGlMappings ? { otherGlMappings } : {}) };
|
||||
}
|
||||
|
||||
isCashOrAccrualAccounting(): boolean {
|
||||
return this.accounting.isCashOrAccrualAccountingRuleId(
|
||||
this.DailyDepositSchemeProductAccountingForm.value.accountingRule
|
||||
);
|
||||
}
|
||||
|
||||
isAccrualAccounting(): boolean {
|
||||
return this.accounting.isAccrualAccountingRuleId(this.DailyDepositSchemeProductAccountingForm.value.accountingRule);
|
||||
}
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="rdpch-section">
|
||||
<div class="rdpch-section__header">
|
||||
<i class="fa fa-file-invoice-dollar"></i>
|
||||
<h3>{{ 'labels.heading.Charges' | translate }}</h3>
|
||||
</div>
|
||||
<div class="rdpch-section__body">
|
||||
<div class="rdpch-add-row">
|
||||
<mat-form-field appearance="outline" class="rdpch-add-row__select">
|
||||
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
|
||||
<mat-select #charge>
|
||||
@for (charge of chargeData | chargesFilter: chargesDataSource : currencyCode.value; track charge) {
|
||||
<mat-option [value]="charge">
|
||||
{{ charge.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button
|
||||
type="button"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="rdpch-add-btn"
|
||||
(click)="addCharge(charge)"
|
||||
[disabled]="!charge.value"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (chargesDataSource.length > 0) {
|
||||
<div class="rdpch-table-wrapper">
|
||||
<table mat-table [dataSource]="chargesDataSource">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">
|
||||
{{ charge.name + ', ' + charge.currency.displaySymbol }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="chargeCalculationType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">
|
||||
{{ charge.chargeCalculationType.value }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">
|
||||
{{ charge.amount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="chargeTimeType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Collected On' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">
|
||||
{{ charge.chargeTimeType.value }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="action">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">
|
||||
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.rdpch-section {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #202124;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpch-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpch-add-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdpch-table-wrapper {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormControl } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-charges-step',
|
||||
templateUrl: './daily-deposit-scheme-product-charges-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-charges-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatIconButton,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
ChargesFilterPipe
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductChargesStepComponent implements OnInit {
|
||||
dialog = inject(MatDialog);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
@Input() currencyCode: UntypedFormControl;
|
||||
|
||||
chargeData: any;
|
||||
|
||||
chargesDataSource: {}[];
|
||||
displayedColumns: string[] = [
|
||||
'name',
|
||||
'chargeCalculationType',
|
||||
'amount',
|
||||
'chargeTimeType',
|
||||
'action'
|
||||
];
|
||||
|
||||
ngOnInit() {
|
||||
this.chargeData = this.DailyDepositSchemeProductsTemplate.chargeOptions;
|
||||
if (!(this.DailyDepositSchemeProductsTemplate === undefined) && this.DailyDepositSchemeProductsTemplate.id) {
|
||||
this.chargesDataSource = this.DailyDepositSchemeProductsTemplate.charges;
|
||||
} else {
|
||||
this.chargesDataSource = [];
|
||||
}
|
||||
this.currencyCode.valueChanges.subscribe(() => (this.chargesDataSource = []));
|
||||
}
|
||||
|
||||
addCharge(charge: any) {
|
||||
this.chargesDataSource = this.chargesDataSource.concat([charge.value]);
|
||||
charge.value = '';
|
||||
}
|
||||
|
||||
deleteCharge(charge: any) {
|
||||
const deleteChargeDialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: this.translateService.instant('labels.inputs.Charge') + ' ' + charge.name }
|
||||
});
|
||||
deleteChargeDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.delete) {
|
||||
this.chargesDataSource.splice(this.chargesDataSource.indexOf(charge), 1);
|
||||
this.chargesDataSource = this.chargesDataSource.concat([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductCharges() {
|
||||
return {
|
||||
charges: this.chargesDataSource
|
||||
};
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="DailyDepositSchemeProductCurrencyForm">
|
||||
<div class="rdpc-section">
|
||||
<div class="rdpc-section__header">
|
||||
<i class="fas fa-coins"></i>
|
||||
<span>Currency & Account Status Configuration</span>
|
||||
</div>
|
||||
<div class="rdpc-body">
|
||||
<div class="rdpc-grid">
|
||||
<mat-form-field appearance="outline" class="rdpc-field">
|
||||
<mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label>
|
||||
<mat-select
|
||||
formControlName="currencyCode"
|
||||
matTooltip="{{ 'tooltips.The currency in which the deposit will be disbursed' | translate }}"
|
||||
required
|
||||
>
|
||||
@for (currency of currencyData; track currency) {
|
||||
<mat-option [value]="currency.code">
|
||||
{{ currency.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>Default: Indian Rupee - auto-set for domestic products</mat-hint>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Currency' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpc-field">
|
||||
<mat-label>{{ 'labels.inputs.Decimal Places' | translate }}</mat-label>
|
||||
<mat-select
|
||||
formControlName="digitsAfterDecimal"
|
||||
matTooltip="{{ 'tooltips.Decimal places to be used to track and report' | translate }}"
|
||||
required
|
||||
>
|
||||
@for (places of decimalPlacesData; track places) {
|
||||
<mat-option [value]="places">{{ places }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Decimal Places' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="rdpc-alert">
|
||||
<div class="rdpc-alert__content">
|
||||
<h4>Dormancy Alert</h4>
|
||||
<p>Enable automated notifications when an account becomes inactive based on product rules.</p>
|
||||
</div>
|
||||
<mat-radio-group class="rdpc-alert__choices" formControlName="dormancyAlert">
|
||||
<mat-radio-button [value]="true">yes</mat-radio-button>
|
||||
<mat-radio-button [value]="false">no</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="rdpc-alert">
|
||||
<div class="rdpc-alert__content">
|
||||
<h4>Nomination Facility</h4>
|
||||
<p>Allow account holders to designate individuals to receive funds in the event of account holder's death.</p>
|
||||
</div>
|
||||
<mat-radio-group class="rdpc-alert__choices" formControlName="nominationFacility">
|
||||
<mat-radio-button [value]="true">yes</mat-radio-button>
|
||||
<mat-radio-button [value]="false">no</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="rdpc-alert">
|
||||
<div class="rdpc-alert__content">
|
||||
<h4>Auto Debit Facility</h4>
|
||||
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
|
||||
</div>
|
||||
<mat-radio-group class="rdpc-alert__choices" formControlName="autoDebitFacility">
|
||||
<mat-radio-button [value]="true">yes</mat-radio-button>
|
||||
<mat-radio-button [value]="false">no</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$accent: #0ea5e9;
|
||||
$border: #e2e8f0;
|
||||
$gray-50: #f8f9fa;
|
||||
$gray-200: #e8eaed;
|
||||
$gray-900: #202124;
|
||||
|
||||
.rdpc-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
& + & {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
span {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdpc-body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rdpc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
padding: 16px 24px;
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpc-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdpc-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 24px 24px;
|
||||
border-top: 1px solid $border;
|
||||
background: #f8fafc;
|
||||
|
||||
@media (width <= 760px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__content {
|
||||
h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
max-width: 540px;
|
||||
}
|
||||
}
|
||||
|
||||
&__choices {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-currency-step',
|
||||
templateUrl: './daily-deposit-scheme-product-currency-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-currency-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTooltip,
|
||||
MatRadioGroup,
|
||||
MatRadioButton
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductCurrencyStepComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
|
||||
DailyDepositSchemeProductCurrencyForm: UntypedFormGroup;
|
||||
|
||||
currencyData: any;
|
||||
decimalPlacesData = [
|
||||
2,
|
||||
0,
|
||||
3
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.createDailyDepositSchemeProductCurrencyForm();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.currencyData = this.DailyDepositSchemeProductsTemplate.currencyOptions.filter(
|
||||
(c: { code: string }) => c.code === 'INR'
|
||||
);
|
||||
|
||||
this.DailyDepositSchemeProductCurrencyForm.patchValue({
|
||||
currencyCode: this.DailyDepositSchemeProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
|
||||
digitsAfterDecimal:
|
||||
this.DailyDepositSchemeProductsTemplate.digitsAfterDecimal ??
|
||||
this.DailyDepositSchemeProductsTemplate.currency?.decimalPlaces ??
|
||||
2,
|
||||
dormancyAlert: this.DailyDepositSchemeProductsTemplate.dormancyAlert ?? false,
|
||||
isDormancyTrackingActive: this.DailyDepositSchemeProductsTemplate.isDormancyTrackingActive ?? false,
|
||||
daysToInactive: this.DailyDepositSchemeProductsTemplate.daysToInactive ?? 30,
|
||||
daysToDormancy: this.DailyDepositSchemeProductsTemplate.daysToDormancy ?? 60,
|
||||
daysToEscheat: this.DailyDepositSchemeProductsTemplate.daysToEscheat ?? 90,
|
||||
nominationFacility: this.DailyDepositSchemeProductsTemplate.nominationFacility ?? false,
|
||||
autoDebitFacility: this.DailyDepositSchemeProductsTemplate.autoDebitFacility ?? false
|
||||
});
|
||||
}
|
||||
|
||||
createDailyDepositSchemeProductCurrencyForm() {
|
||||
this.DailyDepositSchemeProductCurrencyForm = this.formBuilder.group({
|
||||
currencyCode: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
digitsAfterDecimal: [
|
||||
2,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
dormancyAlert: [false],
|
||||
isDormancyTrackingActive: [false],
|
||||
daysToInactive: [30],
|
||||
daysToDormancy: [60],
|
||||
daysToEscheat: [90],
|
||||
nominationFacility: [false],
|
||||
autoDebitFacility: [false]
|
||||
});
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductCurrency() {
|
||||
const formValue = this.DailyDepositSchemeProductCurrencyForm.value;
|
||||
const result: any = {
|
||||
currencyCode: formValue.currencyCode,
|
||||
digitsAfterDecimal: formValue.digitsAfterDecimal,
|
||||
dormancyAlert: formValue.dormancyAlert,
|
||||
nominationFacility: formValue.nominationFacility,
|
||||
autoDebitFacility: formValue.autoDebitFacility
|
||||
};
|
||||
if (formValue.dormancyAlert) {
|
||||
result.daysToInactive = formValue.daysToInactive;
|
||||
result.daysToDormancy = formValue.daysToDormancy;
|
||||
result.daysToEscheat = formValue.daysToEscheat;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="DailyDepositSchemeProductDetailsForm">
|
||||
<div class="rdpd-section">
|
||||
<div class="rdpd-section__header">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<h3>Account Type Creation</h3>
|
||||
</div>
|
||||
<div class="rdpd-grid">
|
||||
<mat-form-field appearance="outline" class="rdpd-field">
|
||||
<mat-label>Account Type Group</mat-label>
|
||||
<input matInput formControlName="accountTypeGroup" required />
|
||||
@if (DailyDepositSchemeProductDetailsForm.controls.accountTypeGroup.hasError('required')) {
|
||||
<mat-error>Account Type Group <strong>is required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpd-field">
|
||||
<mat-label>A/c Type</mat-label>
|
||||
<input matInput formControlName="shortName" maxlength="4" required />
|
||||
@if (DailyDepositSchemeProductDetailsForm.controls.shortName.hasError('required')) {
|
||||
<mat-error>A/c Type <strong>is required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpd-field rdpd-field--full">
|
||||
<mat-label>A/c Type Description</mat-label>
|
||||
<input matInput formControlName="name" required />
|
||||
@if (DailyDepositSchemeProductDetailsForm.controls.name.hasError('required')) {
|
||||
<mat-error>A/c Type Description <strong>is required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpd-field">
|
||||
<mat-label>Interest Rate (%)</mat-label>
|
||||
<input matInput type="number" formControlName="interestRate" required />
|
||||
@if (DailyDepositSchemeProductDetailsForm.controls.interestRate.hasError('required')) {
|
||||
<mat-error>Interest Rate <strong>is required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpd-field">
|
||||
<mat-label>Auto Interest Post</mat-label>
|
||||
<mat-select formControlName="autoInterestPost">
|
||||
@for (option of yesNoOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (DailyDepositSchemeProductDetailsForm.get('autoInterestPost')?.value) {
|
||||
<mat-form-field appearance="outline" class="rdpd-field">
|
||||
<mat-label>Auto Interest Post When</mat-label>
|
||||
<mat-select formControlName="autoInterestPostWhen" required>
|
||||
@for (option of autoInterestPostWhenData; track option.id) {
|
||||
<mat-option [value]="option.id">{{ option.value }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (DailyDepositSchemeProductDetailsForm.controls.autoInterestPostWhen.hasError('required')) {
|
||||
<mat-error>Auto Interest Post When <strong>is required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpd-field">
|
||||
<mat-label>Auto Interest Post Day</mat-label>
|
||||
<input matInput type="number" formControlName="autoInterestPostDay" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$accent: #0ea5e9;
|
||||
$border: #e2e8f0;
|
||||
$gray-50: #f8f9fa;
|
||||
$gray-200: #e8eaed;
|
||||
$gray-900: #202124;
|
||||
|
||||
.rdpd-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
& + & {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdpd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
padding: 16px 24px;
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpd-grid--classification {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@media (width <= 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpd-field {
|
||||
width: 100%;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpd-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
}
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnChanges, Input, SimpleChanges, inject, ChangeDetectorRef } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-details-step',
|
||||
templateUrl: './daily-deposit-scheme-product-details-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-details-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTooltip,
|
||||
CdkTextareaAutosize
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductDetailsStepComponent implements OnInit, OnChanges {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dateUtils = inject(Dates);
|
||||
private settingsService = inject(SettingsService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
|
||||
DailyDepositSchemeProductDetailsForm: UntypedFormGroup;
|
||||
accountTypeData: string[] = [];
|
||||
customerSegmentData: string[] = [];
|
||||
liabilityAccountOptions: any[] = [];
|
||||
/** "Auto Interest Post When" options - the legacy screen's own field, sourced from the same
|
||||
* enum the (hidden) interest-posting-period setting uses, so the two always stay in sync. */
|
||||
autoInterestPostWhenData: any[] = [];
|
||||
yesNoOptions: Array<{ value: boolean; label: string }> = [
|
||||
{ value: true, label: 'Yes' },
|
||||
{ value: false, label: 'No' }
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.createDailyDepositSchemeProductDetailsForm();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.DailyDepositSchemeProductsTemplate) {
|
||||
this.applyTemplate(this.DailyDepositSchemeProductsTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.DailyDepositSchemeProductsTemplate?.currentValue) {
|
||||
this.applyTemplate(changes.DailyDepositSchemeProductsTemplate.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
createDailyDepositSchemeProductDetailsForm() {
|
||||
this.DailyDepositSchemeProductDetailsForm = this.formBuilder.group({
|
||||
accountTypeGroup: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
name: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
shortName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
description: [''],
|
||||
autoInterestPost: [true],
|
||||
autoInterestPostWhen: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
autoInterestPostDay: [''],
|
||||
interestRate: [
|
||||
2,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
accountType: [
|
||||
null,
|
||||
Validators.required
|
||||
],
|
||||
glAccountId: [null],
|
||||
effectiveTo: [''],
|
||||
endDate: [''],
|
||||
introducerRequired: [false],
|
||||
shareLinkingEnabled: [false]
|
||||
});
|
||||
}
|
||||
|
||||
get minEndDate(): Date | null {
|
||||
const v = this.DailyDepositSchemeProductDetailsForm.get('effectiveTo')?.value;
|
||||
if (!v) return null;
|
||||
const d = new Date(v);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
private getOptionNames(options: any[]): string[] {
|
||||
if (!Array.isArray(options)) {
|
||||
return [];
|
||||
}
|
||||
return options
|
||||
.map((option) => option?.name ?? option?.value ?? option)
|
||||
.filter((value) => typeof value === 'string' && value.trim().length > 0);
|
||||
}
|
||||
|
||||
private parseDate(dateValue: any): Date | null {
|
||||
if (!dateValue) return null;
|
||||
if (Array.isArray(dateValue)) {
|
||||
return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]);
|
||||
}
|
||||
if (dateValue instanceof Date) {
|
||||
return dateValue;
|
||||
}
|
||||
return new Date(dateValue);
|
||||
}
|
||||
|
||||
compareById(o1: any, o2: any): boolean {
|
||||
return o1 != null && o2 != null && Number(o1) === Number(o2);
|
||||
}
|
||||
|
||||
private applyTemplate(template: any) {
|
||||
this.accountTypeData = this.getOptionNames(template.accountTypeOptions);
|
||||
this.liabilityAccountOptions = template.accountingMappingOptions?.liabilityAccountOptions ?? [];
|
||||
this.autoInterestPostWhenData = template.interestPostingPeriodTypeOptions ?? [];
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const isEditing = !!template.id;
|
||||
this.DailyDepositSchemeProductDetailsForm.patchValue({
|
||||
accountTypeGroup: template.accountTypeGroup || 'DDS',
|
||||
name: isEditing ? template.name : template.name || '',
|
||||
shortName: isEditing ? template.shortName : template.shortName || '',
|
||||
description: template.description || '',
|
||||
autoInterestPost: template.interestPostingPeriodType ? true : (template.autoInterestPost ?? true),
|
||||
autoInterestPostWhen: template.interestPostingPeriodType?.id ?? this.autoInterestPostWhenData[0]?.id ?? '',
|
||||
autoInterestPostDay: template.autoInterestPostDay ?? template.frequencyFixDay ?? '',
|
||||
interestRate: template.nominalAnnualInterestRate ?? 2,
|
||||
accountType: template.accountType ?? this.accountTypeData[0] ?? null,
|
||||
glAccountId: template.glAccountId ?? null,
|
||||
effectiveTo: this.parseDate(template.effectiveTo),
|
||||
endDate: this.parseDate(template.endDate),
|
||||
introducerRequired: template.introducerRequired === true || template.introducerRequired === 'Yes',
|
||||
shareLinkingEnabled: !!template.shareLinkingEnabled
|
||||
});
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductDetails() {
|
||||
const formValue = { ...this.DailyDepositSchemeProductDetailsForm.value };
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
if (formValue.effectiveTo instanceof Date) {
|
||||
formValue.effectiveTo = this.dateUtils.formatDate(formValue.effectiveTo, dateFormat) || '';
|
||||
}
|
||||
if (formValue.endDate instanceof Date) {
|
||||
formValue.endDate = this.dateUtils.formatDate(formValue.endDate, dateFormat) || '';
|
||||
}
|
||||
|
||||
// accountTypeGroup / autoInterestPost / autoInterestPostWhen are the legacy screen's own
|
||||
// fields - they don't map to a real product API parameter by that name, so they're folded
|
||||
// into the actual payload fields (description, autoPost, interestPostingPeriodType) by the
|
||||
// parent component rather than sent through as-is.
|
||||
return formValue;
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<!-- ── Bank Interest Rate Chart (editable reference table from DB) ───────── -->
|
||||
<div class="rdpi-section rdpi-ref-section">
|
||||
<div class="rdpi-section__header">
|
||||
<i class="fa fa-table"></i>
|
||||
<h3>Bank Interest Rate Chart <span class="rdpi-ref-badge">Reference / Editable</span></h3>
|
||||
<span class="rdpi-spacer"></span>
|
||||
<button type="button" mat-raised-button color="primary" class="rdpi-add-btn" (click)="addSlab()">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
Add Slab
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_referenceVersionLabel) {
|
||||
<div class="rdpi-ref-meta">
|
||||
<span><strong>Version:</strong> {{ _referenceVersionLabel }}</span>
|
||||
@if (_referenceRemarks) {
|
||||
<span> | {{ _referenceRemarks }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_editableSlabs.length === 0) {
|
||||
<div class="rdpi-empty-msg">
|
||||
<h3>No interest rate slabs defined. Click <strong>Add Slab</strong> to add one.</h3>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_editableSlabs.length > 0) {
|
||||
<div class="rdpi-table-wrapper">
|
||||
<table mat-table [dataSource]="_editableSlabs" class="rdpi-ref-table">
|
||||
<ng-container matColumnDef="srNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Sr.</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Particular</th>
|
||||
<td mat-cell *matCellDef="let slab; let i = index">
|
||||
<input
|
||||
class="rdpi-inline-input"
|
||||
[value]="slab.description"
|
||||
(change)="editSlab(i, 'description', $any($event.target).value)"
|
||||
/>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fromPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let slab; let i = index">
|
||||
<input
|
||||
class="rdpi-inline-input rdpi-inline-input--num"
|
||||
type="number"
|
||||
[value]="slab.fromPeriod"
|
||||
(change)="editSlab(i, 'fromPeriod', +$any($event.target).value)"
|
||||
/>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="toPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let slab; let i = index">
|
||||
<input
|
||||
class="rdpi-inline-input rdpi-inline-input--num"
|
||||
type="number"
|
||||
[value]="slab.toPeriod ?? ''"
|
||||
(change)="editSlab(i, 'toPeriod', $any($event.target).value ? +$any($event.target).value : null)"
|
||||
/>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="periodTypeLabel">
|
||||
<th mat-header-cell *matHeaderCellDef>Period</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.periodTypeLabel }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="annualInterestRate">
|
||||
<th mat-header-cell *matHeaderCellDef>Rate (%)</th>
|
||||
<td mat-cell *matCellDef="let slab; let i = index">
|
||||
<input
|
||||
class="rdpi-inline-input rdpi-inline-input--rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
[value]="slab.annualInterestRate"
|
||||
(change)="editSlab(i, 'annualInterestRate', +$any($event.target).value)"
|
||||
/>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="refSlabColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: refSlabColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="rdpi-ref-footer">
|
||||
<p class="rdpi-ref-hint">
|
||||
Edit the rates above. When you <strong>Save</strong> the product, a new version will be inserted in the database
|
||||
— previous rates are preserved as history.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.rdpi-section {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
& + & {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #202124;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdpi-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rdpi-content {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.rdpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.rdpi-field {
|
||||
width: 100%;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpi-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.rdpi-add-btn {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdpi-chart-block {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.rdpi-chart-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.rdpi-slab-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.rdpi-empty-msg {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #5f6368;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpi-table-wrapper {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
tr.incentives-row {
|
||||
height: 0;
|
||||
|
||||
> td {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdpi-incentives {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rdpi-incentive-card {
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rdpi-incentive-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.rdpi-incentive-add {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.rdpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Editable reference-rate table ───────────────────────────── */
|
||||
.rdpi-ref-section {
|
||||
margin-top: 24px;
|
||||
border-color: #bfdbfe;
|
||||
background: #f0f7ff;
|
||||
|
||||
.rdpi-section__header {
|
||||
background: #dbeafe;
|
||||
color: #1e3a5f;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpi-ref-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rdpi-ref-meta {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.rdpi-ref-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdpi-inline-input {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&--num {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
&--rate {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpi-ref-footer {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.rdpi-ref-hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
-717
@@ -1,717 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnChanges, Input, inject } from '@angular/core';
|
||||
import {
|
||||
UntypedFormGroup,
|
||||
UntypedFormBuilder,
|
||||
Validators,
|
||||
UntypedFormArray,
|
||||
ReactiveFormsModule
|
||||
} from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { animate, state, style, transition, trigger } from '@angular/animations';
|
||||
|
||||
/** Custom Components */
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { DepositProductIncentiveFormDialogComponent } from 'app/products/deposit-product-incentive-form-dialog/deposit-product-incentive-form-dialog.component';
|
||||
|
||||
/** Dialog Components */
|
||||
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
||||
import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base';
|
||||
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
|
||||
import { FindPipe } from '../../../../pipes/find.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-interest-rate-chart-step',
|
||||
templateUrl: './daily-deposit-scheme-product-interest-rate-chart-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-interest-rate-chart-step.component.scss'],
|
||||
animations: [
|
||||
trigger('expandChartSlab', [
|
||||
state('collapsed', style({ height: '0px', minHeight: '0' })),
|
||||
state('expanded', style({ height: '*' })),
|
||||
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
|
||||
])
|
||||
],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatDivider,
|
||||
MatButton,
|
||||
MatCheckbox,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
FindPipe
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductInterestRateChartStepComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
dialog = inject(MatDialog);
|
||||
private dateUtils = inject(Dates);
|
||||
private settingsService = inject(SettingsService);
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
/** Reference rate chart fetched from m_rd_interest_rate_effective / m_rd_interest_rate_slab_history */
|
||||
@Input() set rdInterestRates(rates: any[]) {
|
||||
if (rates && rates.length > 0) {
|
||||
// Use the first active version's slabs as the editable working copy
|
||||
const activeVersion = rates.find((v: any) => v.isActive) ?? rates[0];
|
||||
this._referenceVersionLabel = activeVersion.versionLabel;
|
||||
this._referenceRemarks = activeVersion.remarks;
|
||||
// Deep-clone slabs so the original is not mutated
|
||||
this._editableSlabs = (activeVersion.slabs ?? []).map((s: any) => ({ ...s }));
|
||||
this.ensureSlabRangeRules(this._editableSlabs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Slabs the user is editing in the reference-rate table */
|
||||
_editableSlabs: any[] = [];
|
||||
_referenceVersionLabel = '';
|
||||
_referenceRemarks = '';
|
||||
|
||||
/** Columns for the editable reference-rate table */
|
||||
readonly refSlabColumns = [
|
||||
'srNo',
|
||||
'description',
|
||||
'fromPeriod',
|
||||
'toPeriod',
|
||||
'periodTypeLabel',
|
||||
'annualInterestRate'
|
||||
];
|
||||
|
||||
/** Returns the payload to save as a new rate version (called by parent after product save) */
|
||||
get modifiedRdInterestRatePayload(): any {
|
||||
return {
|
||||
versionLabel: this._referenceVersionLabel || 'Rate Version ' + new Date().toISOString().slice(0, 10),
|
||||
remarks: this._referenceRemarks,
|
||||
slabs: this._editableSlabs.map((s: any, idx: number) => ({
|
||||
srNo: s.srNo ?? idx + 1,
|
||||
description: s.description,
|
||||
periodTypeEnum: s.periodTypeEnum ?? 1,
|
||||
fromPeriod: s.fromPeriod,
|
||||
toPeriod: s.toPeriod ?? null,
|
||||
annualInterestRate: s.annualInterestRate
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
editSlab(index: number, field: string, value: any) {
|
||||
const slabs = this._editableSlabs.map((slab: any) => ({ ...slab }));
|
||||
const current = slabs[index];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = this.normalizeSlabValue(value);
|
||||
const oldFrom = this.normalizeSlabValue(current.fromPeriod);
|
||||
const oldTo = this.normalizeSlabValue(current.toPeriod);
|
||||
|
||||
current[field] = nextValue;
|
||||
|
||||
// Keep the first slab anchored at 0 so the chart always starts from zero.
|
||||
if (index === 0 && field === 'fromPeriod') {
|
||||
current.fromPeriod = 0;
|
||||
}
|
||||
|
||||
if (field === 'fromPeriod' && nextValue === null) {
|
||||
current.fromPeriod = oldFrom;
|
||||
}
|
||||
|
||||
if (field === 'fromPeriod') {
|
||||
const effectiveFrom = this.normalizeSlabValue(current.fromPeriod);
|
||||
const delta = effectiveFrom - oldFrom;
|
||||
|
||||
if (current.toPeriod !== null && current.toPeriod !== undefined) {
|
||||
current.toPeriod = this.normalizeSlabValue(current.toPeriod) + delta;
|
||||
}
|
||||
|
||||
this.shiftFollowingSlabs(slabs, index, delta);
|
||||
}
|
||||
|
||||
if (field === 'toPeriod') {
|
||||
if (index < slabs.length - 1 && nextValue === null) {
|
||||
// Non-final slabs should always have a closing boundary so later slabs can stay contiguous.
|
||||
current.toPeriod = oldTo;
|
||||
} else {
|
||||
const delta = (this.normalizeSlabValue(current.toPeriod) ?? 0) - (oldTo ?? 0);
|
||||
this.shiftFollowingSlabs(slabs, index, delta);
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureSlabRangeRules(slabs);
|
||||
this._editableSlabs = slabs;
|
||||
}
|
||||
|
||||
addSlab() {
|
||||
const slabs = this._editableSlabs.map((slab: any) => ({ ...slab }));
|
||||
const nextSr = slabs.length + 1;
|
||||
const lastSlab = slabs[slabs.length - 1];
|
||||
const lastFrom = this.normalizeSlabValue(lastSlab?.fromPeriod) ?? 0;
|
||||
|
||||
if (lastSlab && this.normalizeSlabValue(lastSlab.toPeriod) === null) {
|
||||
// Split the open-ended tail so the new slab can become the new last row.
|
||||
// This keeps the sequence contiguous while preserving the existing tail start.
|
||||
lastSlab.toPeriod = lastFrom;
|
||||
}
|
||||
|
||||
const previousSlab = slabs[slabs.length - 1];
|
||||
this._editableSlabs = [
|
||||
...slabs,
|
||||
{
|
||||
srNo: nextSr,
|
||||
description: '',
|
||||
periodTypeEnum: 1,
|
||||
periodTypeLabel: 'Days',
|
||||
fromPeriod: slabs.length === 0 ? 0 : this.nextFromPeriod(previousSlab),
|
||||
toPeriod: null,
|
||||
annualInterestRate: null
|
||||
}
|
||||
];
|
||||
this.ensureSlabRangeRules(this._editableSlabs);
|
||||
}
|
||||
|
||||
private normalizeSlabValue(value: any): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
private nextFromPeriod(previousSlab: any): number {
|
||||
const previousTo = this.normalizeSlabValue(previousSlab?.toPeriod);
|
||||
const previousFrom = this.normalizeSlabValue(previousSlab?.fromPeriod) ?? 0;
|
||||
return previousTo === null ? previousFrom + 1 : previousTo + 1;
|
||||
}
|
||||
|
||||
private shiftFollowingSlabs(slabs: any[], index: number, delta: number): void {
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = index + 1; i < slabs.length; i++) {
|
||||
const slab = slabs[i];
|
||||
const currentFrom = this.normalizeSlabValue(slab.fromPeriod);
|
||||
const currentTo = this.normalizeSlabValue(slab.toPeriod);
|
||||
|
||||
if (currentFrom !== null) {
|
||||
slab.fromPeriod = currentFrom + delta;
|
||||
}
|
||||
|
||||
if (currentTo !== null) {
|
||||
slab.toPeriod = currentTo + delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSlabRangeRules(slabs: any[]): void {
|
||||
if (slabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
slabs[0].fromPeriod = 0;
|
||||
|
||||
for (let i = 1; i < slabs.length; i++) {
|
||||
const previous = slabs[i - 1];
|
||||
const previousTo = this.normalizeSlabValue(previous.toPeriod);
|
||||
if (previousTo === null) {
|
||||
slabs[i].fromPeriod = this.normalizeSlabValue(slabs[i].fromPeriod);
|
||||
continue;
|
||||
}
|
||||
|
||||
slabs[i].fromPeriod = previousTo + 1;
|
||||
}
|
||||
|
||||
// Keep the last slab open-ended so the chart ends with null.
|
||||
slabs[slabs.length - 1].toPeriod = null;
|
||||
|
||||
// Prevent inverted ranges on intermediate rows.
|
||||
for (let i = 0; i < slabs.length - 1; i++) {
|
||||
const fromPeriod = this.normalizeSlabValue(slabs[i].fromPeriod);
|
||||
const toPeriod = this.normalizeSlabValue(slabs[i].toPeriod);
|
||||
if (fromPeriod !== null && toPeriod !== null && toPeriod < fromPeriod) {
|
||||
slabs[i].toPeriod = fromPeriod;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DailyDepositSchemeProductInterestRateChartForm: UntypedFormGroup;
|
||||
|
||||
periodTypeData: any;
|
||||
entityTypeData: any;
|
||||
attributeNameData: any;
|
||||
conditionTypeData: any;
|
||||
genderData: any;
|
||||
clientTypeData: any;
|
||||
clientClassificationData: any;
|
||||
incentiveTypeData: any;
|
||||
|
||||
chartSlabsDisplayedColumns: any[] = [];
|
||||
chartSlabsIncentivesDisplayedColumns: string[] = ['incentives'];
|
||||
incentivesDisplayedColumns: string[] = [
|
||||
'entityType',
|
||||
'attributeName',
|
||||
'conditionType',
|
||||
'attributeValue',
|
||||
'incentiveType',
|
||||
'amount',
|
||||
'actions'
|
||||
];
|
||||
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10));
|
||||
|
||||
expandChartSlabIndex: number[] = [];
|
||||
chartDetailData: any = [];
|
||||
chartsDetail: any[] = [];
|
||||
|
||||
/**
|
||||
* @param {FormBuilder} formBuilder Form Builder.
|
||||
* @param {MatDialog} dialog Dialog reference.
|
||||
* @param {Dates} dateUtils Date Utils to format date.
|
||||
* @param {SettingsService} settingsService Settings Service.
|
||||
*/
|
||||
|
||||
constructor() {
|
||||
this.createDailyDepositSchemeProductInterestRateChartForm();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.periodTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.periodTypes;
|
||||
this.entityTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.entityTypeOptions;
|
||||
this.attributeNameData = this.DailyDepositSchemeProductsTemplate.chartTemplate.attributeNameOptions;
|
||||
this.conditionTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.conditionTypeOptions;
|
||||
this.genderData = this.DailyDepositSchemeProductsTemplate.chartTemplate.genderOptions;
|
||||
this.clientTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.clientTypeOptions;
|
||||
this.clientClassificationData = this.DailyDepositSchemeProductsTemplate.chartTemplate.clientClassificationOptions;
|
||||
this.incentiveTypeData = this.DailyDepositSchemeProductsTemplate.chartTemplate.incentiveTypeOptions;
|
||||
|
||||
// assignFormData() intentionally not called — old chart form is removed from UI.
|
||||
// The DailyDepositSchemeProductInterestRateChartForm is kept only for stepControl
|
||||
// validity; it remains empty (valid) so the preview tab becomes available.
|
||||
}
|
||||
|
||||
// Assign form the values for edit Daily Deposit Scheme
|
||||
assignFormData() {
|
||||
this.addChart();
|
||||
const isChartArray = Array.isArray(this.DailyDepositSchemeProductsTemplate.activeChart);
|
||||
if (this.DailyDepositSchemeProductsTemplate.activeChart) {
|
||||
if (!isChartArray) {
|
||||
this.chartDetailData.push(this.DailyDepositSchemeProductsTemplate.activeChart);
|
||||
} else {
|
||||
this.chartDetailData = this.DailyDepositSchemeProductsTemplate.activeChart;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the array of Objects from the retrived value
|
||||
this.getChartsDetailsData();
|
||||
|
||||
// Iterates for every chart in charts
|
||||
this.charts.controls.forEach((chartDetailControl: UntypedFormGroup, i: number) => {
|
||||
if (!this.chartsDetail[i]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate for every chartSlab in chart
|
||||
this.chartsDetail[i].chartSlabs.forEach((chartSlabDetail: any, j: number) => {
|
||||
const chartSlabInfo = this.formBuilder.group({
|
||||
id: [chartSlabDetail.id],
|
||||
amountRangeFrom: [chartSlabDetail.amountRangeFrom],
|
||||
amountRangeTo: [chartSlabDetail.amountRangeTo],
|
||||
annualInterestRate: [
|
||||
chartSlabDetail.annualInterestRate,
|
||||
Validators.required
|
||||
],
|
||||
description: [
|
||||
chartSlabDetail.description,
|
||||
Validators.required
|
||||
],
|
||||
fromPeriod: [
|
||||
chartSlabDetail.fromPeriod,
|
||||
Validators.required
|
||||
],
|
||||
toPeriod: [chartSlabDetail.toPeriod],
|
||||
periodType: [
|
||||
chartSlabDetail.periodType,
|
||||
Validators.required
|
||||
],
|
||||
incentives: this.formBuilder.array([])
|
||||
});
|
||||
const formArray = chartDetailControl.controls['chartSlabs'] as UntypedFormArray;
|
||||
formArray.push(chartSlabInfo);
|
||||
|
||||
// Iterate for every slab in chartSlab
|
||||
const chartIncentiveControl = (chartDetailControl.controls['chartSlabs'] as UntypedFormArray).controls[j];
|
||||
|
||||
// Iterate to input all the incentive for particular chart slab
|
||||
this.chartsDetail[i].chartSlabs[j].incentives.forEach((chartIncentiveDetail: any) => {
|
||||
const incentiveInfo = this.formBuilder.group({
|
||||
amount: [
|
||||
chartIncentiveDetail.amount,
|
||||
Validators.required
|
||||
],
|
||||
attributeName: [
|
||||
chartIncentiveDetail.attributeName,
|
||||
Validators.required
|
||||
],
|
||||
attribureValue: [
|
||||
chartIncentiveDetail.attribureValue,
|
||||
Validators.required
|
||||
],
|
||||
conditionType: [
|
||||
chartIncentiveDetail.conditionType,
|
||||
Validators.required
|
||||
],
|
||||
entityType: [
|
||||
chartIncentiveDetail.entityType,
|
||||
Validators.required
|
||||
],
|
||||
incentiveType: [
|
||||
chartIncentiveDetail.incentiveType,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
const newFormArray = (chartIncentiveControl as UntypedFormGroup).controls['incentives'] as UntypedFormArray;
|
||||
newFormArray.push(incentiveInfo);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getChartsDetailsData() {
|
||||
this.chartDetailData.forEach((chartData: any) => {
|
||||
const chart: {
|
||||
id?: any;
|
||||
endDate: string | Date;
|
||||
fromDate: string | Date;
|
||||
isPrimaryGroupingByAmount: any;
|
||||
name: any;
|
||||
chartSlabs: any[];
|
||||
} = {
|
||||
endDate: chartData.endDate ? new Date(chartData.endDate) : '',
|
||||
fromDate: chartData.fromDate ? new Date(chartData.fromDate) : '',
|
||||
isPrimaryGroupingByAmount: chartData.isPrimaryGroupingByAmount,
|
||||
name: chartData.name,
|
||||
chartSlabs: this.getChartSlabsData(chartData)
|
||||
};
|
||||
if (chartData.id) {
|
||||
chart['id'] = chartData.id;
|
||||
}
|
||||
this.chartsDetail.push(chart);
|
||||
});
|
||||
this.DailyDepositSchemeProductInterestRateChartForm.patchValue({
|
||||
charts: this.chartsDetail
|
||||
});
|
||||
}
|
||||
|
||||
getChartSlabsData(chartData: any) {
|
||||
const chartSlabs: any[] = [];
|
||||
let chartSlabData: any[] = [];
|
||||
const isChartSlabArray = Array.isArray(chartData.chartSlabs);
|
||||
if (!isChartSlabArray) {
|
||||
chartSlabData.push(chartData.chartSlabs);
|
||||
} else {
|
||||
chartSlabData = chartData.chartSlabs;
|
||||
}
|
||||
|
||||
chartSlabData.forEach((eachChartSlabData: any) => {
|
||||
const chartSlab: {
|
||||
id?: any;
|
||||
periodType: any;
|
||||
amountRangeFrom: any;
|
||||
amountRangeTo: any;
|
||||
annualInterestRate: any;
|
||||
description: any;
|
||||
fromPeriod: any;
|
||||
toPeriod: any;
|
||||
incentives: any[];
|
||||
} = {
|
||||
periodType: eachChartSlabData.periodType.id,
|
||||
amountRangeFrom: eachChartSlabData.amountRangeFrom,
|
||||
amountRangeTo: eachChartSlabData.amountRangeTo,
|
||||
annualInterestRate: eachChartSlabData.annualInterestRate,
|
||||
description: eachChartSlabData.description ? eachChartSlabData.description : '',
|
||||
fromPeriod: eachChartSlabData.fromPeriod,
|
||||
toPeriod: eachChartSlabData.toPeriod,
|
||||
incentives: this.getIncentivesData(chartSlabData)
|
||||
};
|
||||
if (eachChartSlabData.id) {
|
||||
chartSlab['id'] = eachChartSlabData.id;
|
||||
}
|
||||
chartSlabs.push(chartSlab);
|
||||
});
|
||||
return chartSlabs;
|
||||
}
|
||||
|
||||
getIncentivesData(chartSlabData: any) {
|
||||
const incentives: any[] = [];
|
||||
let incentiveDatas: any[] = [];
|
||||
if (chartSlabData.incentives) {
|
||||
const isChartIncentiveArray = Array.isArray(chartSlabData.incentives);
|
||||
if (!isChartIncentiveArray) {
|
||||
incentiveDatas.push(chartSlabData.incentives);
|
||||
} else {
|
||||
incentiveDatas = chartSlabData.incentives;
|
||||
}
|
||||
incentiveDatas.forEach((incentiveData: any) => {
|
||||
const incentive = {
|
||||
amount: incentiveData.amount,
|
||||
attributeName: incentiveData.attributeName,
|
||||
attributeValue: incentiveData.attributeValue,
|
||||
conditionType: incentiveData.conditionType,
|
||||
entityType: incentiveData.entityType,
|
||||
incentiveType: incentiveData.incentiveType
|
||||
};
|
||||
incentives.push(incentive);
|
||||
});
|
||||
}
|
||||
return incentives;
|
||||
}
|
||||
|
||||
createDailyDepositSchemeProductInterestRateChartForm() {
|
||||
this.DailyDepositSchemeProductInterestRateChartForm = this.formBuilder.group({
|
||||
charts: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
get charts(): UntypedFormArray {
|
||||
return this.DailyDepositSchemeProductInterestRateChartForm.get('charts') as UntypedFormArray;
|
||||
}
|
||||
|
||||
createChartForm(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [null],
|
||||
name: [''],
|
||||
description: [''],
|
||||
fromDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
endDate: [''],
|
||||
isPrimaryGroupingByAmount: [false],
|
||||
chartSlabs: this.formBuilder.array([], Validators.required)
|
||||
});
|
||||
}
|
||||
|
||||
addChart() {
|
||||
this.charts.push(this.createChartForm());
|
||||
this.setConditionalControls(this.charts.length - 1);
|
||||
}
|
||||
|
||||
setConditionalControls(chartIndex: number) {
|
||||
this.chartSlabsDisplayedColumns[chartIndex] = [
|
||||
'period',
|
||||
'amountRange',
|
||||
'annualInterestRate',
|
||||
'description',
|
||||
'actions'
|
||||
];
|
||||
this.charts
|
||||
.at(chartIndex)
|
||||
.get('isPrimaryGroupingByAmount')
|
||||
.valueChanges.subscribe((isPrimaryGroupingByAmount: boolean) => {
|
||||
this.chartSlabsDisplayedColumns[chartIndex] = isPrimaryGroupingByAmount ? [
|
||||
'amountRange',
|
||||
'period'
|
||||
] : [
|
||||
'period',
|
||||
'amountRange'
|
||||
];
|
||||
this.chartSlabsDisplayedColumns[chartIndex].push('annualInterestRate', 'description', 'actions');
|
||||
});
|
||||
}
|
||||
|
||||
getIncentives(chartSlabs: UntypedFormArray, chartSlabIndex: number): UntypedFormArray {
|
||||
return chartSlabs.at(chartSlabIndex).get('incentives') as UntypedFormArray;
|
||||
}
|
||||
|
||||
addChartSlab(chartSlabs: UntypedFormArray) {
|
||||
const data = { ...this.getData('Slab') };
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
response.data.addControl('incentives', this.formBuilder.array([]));
|
||||
chartSlabs.push(response.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addIncentive(incentives: UntypedFormArray) {
|
||||
const data = { ...this.getData('Incentive'), entityType: this.entityTypeData[0].id };
|
||||
const dialogRef = this.dialog.open(DepositProductIncentiveFormDialogComponent, { data });
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
incentives.push(response.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editChartSlab(chartSlabs: UntypedFormArray, chartSlabIndex: number) {
|
||||
const data = { ...this.getData('Slab', chartSlabs.at(chartSlabIndex).value), layout: { addButtonText: 'Edit' } };
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
chartSlabs.at(chartSlabIndex).patchValue(response.data.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editIncentive(incentives: UntypedFormArray, incentiveIndex: number) {
|
||||
const data = {
|
||||
...this.getData('Incentive', incentives.at(incentiveIndex).value),
|
||||
layout: { addButtonText: 'Edit' }
|
||||
};
|
||||
const dialogRef = this.dialog.open(DepositProductIncentiveFormDialogComponent, { data });
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
incentives.at(incentiveIndex).patchValue(response.data.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getData(formType: string, values?: any) {
|
||||
switch (formType) {
|
||||
case 'Slab':
|
||||
return {
|
||||
title: this.translateService.instant('labels.inputs.Slab'),
|
||||
formfields: this.getSlabFormfields(values)
|
||||
};
|
||||
case 'Incentive':
|
||||
return { values, chartTemplate: this.DailyDepositSchemeProductsTemplate.chartTemplate };
|
||||
}
|
||||
}
|
||||
|
||||
getSlabFormfields(values?: any) {
|
||||
const formfields: FormfieldBase[] = [
|
||||
new SelectBase({
|
||||
controlName: 'periodType',
|
||||
label: this.translateService.instant('labels.inputs.Period Type'),
|
||||
value: values ? values.periodType : this.periodTypeData[0].id,
|
||||
options: { label: 'value', value: 'id', data: this.periodTypeData },
|
||||
required: true,
|
||||
order: 1
|
||||
}),
|
||||
new InputBase({
|
||||
controlName: 'fromPeriod',
|
||||
label: this.translateService.instant('labels.inputs.Period From'),
|
||||
value: values ? values.fromPeriod : undefined,
|
||||
type: 'number',
|
||||
required: true,
|
||||
order: 2,
|
||||
min: 0
|
||||
}),
|
||||
new InputBase({
|
||||
controlName: 'toPeriod',
|
||||
label: this.translateService.instant('labels.inputs.Period To'),
|
||||
value: values ? values.toPeriod : undefined,
|
||||
type: 'number',
|
||||
order: 3,
|
||||
min: 0
|
||||
}),
|
||||
new InputBase({
|
||||
controlName: 'amountRangeFrom',
|
||||
label: this.translateService.instant('labels.inputs.Amount Range From'),
|
||||
value: values ? values.amountRangeFrom : undefined,
|
||||
type: 'number',
|
||||
order: 4,
|
||||
min: 0
|
||||
}),
|
||||
new InputBase({
|
||||
controlName: 'amountRangeTo',
|
||||
label: this.translateService.instant('labels.inputs.Amount Range To'),
|
||||
value: values ? values.amountRangeTo : undefined,
|
||||
type: 'number',
|
||||
order: 5,
|
||||
min: 0
|
||||
}),
|
||||
new InputBase({
|
||||
controlName: 'annualInterestRate',
|
||||
label: this.translateService.instant('labels.inputs.Interest'),
|
||||
value: values ? values.annualInterestRate : undefined,
|
||||
type: 'number',
|
||||
required: true,
|
||||
order: 6,
|
||||
min: 0
|
||||
}),
|
||||
new InputBase({
|
||||
controlName: 'description',
|
||||
label: this.translateService.instant('labels.inputs.Description'),
|
||||
value: values ? values.description : undefined,
|
||||
required: true,
|
||||
order: 7
|
||||
})
|
||||
];
|
||||
return formfields;
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductInterestRateChart() {
|
||||
// TODO: Update once language and date settings are setup
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
const locale = this.settingsService.language.code;
|
||||
const DailyDepositSchemeProductInterestRateChart = this.DailyDepositSchemeProductInterestRateChartForm.value;
|
||||
for (const chart of DailyDepositSchemeProductInterestRateChart.charts) {
|
||||
chart.locale = locale;
|
||||
chart.dateFormat = 'yyyy-MM-dd';
|
||||
if (chart.fromDate instanceof Date) {
|
||||
chart.fromDate = this.dateUtils.formatDateAsString(chart.fromDate, dateFormat);
|
||||
}
|
||||
if (chart.endDate) {
|
||||
if (chart.endDate instanceof Date) {
|
||||
chart.endDate = this.dateUtils.formatDateAsString(chart.endDate, dateFormat);
|
||||
}
|
||||
}
|
||||
if (chart.endDate === '') {
|
||||
delete chart.endDate;
|
||||
}
|
||||
if (chart.description === '') {
|
||||
delete chart.description;
|
||||
}
|
||||
if (chart.id === null) {
|
||||
delete chart.id;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...DailyDepositSchemeProductInterestRateChart,
|
||||
rdSlabs: this._editableSlabs
|
||||
};
|
||||
}
|
||||
}
|
||||
-732
@@ -1,732 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="responsive-column">
|
||||
<!-- ── Product Name ── -->
|
||||
<h2 class="mat-h2 flex-100">{{ DailyDepositSchemeProduct.name }}</h2>
|
||||
|
||||
<!-- ── Details ── -->
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Details' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Short Name' | translate }}:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.shortName }}</span>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.description) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Description' | translate }}:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.description }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Product Classification ── -->
|
||||
@if (
|
||||
DailyDepositSchemeProduct.accountType ||
|
||||
DailyDepositSchemeProduct.customerSegment ||
|
||||
DailyDepositSchemeProduct.glAccountId ||
|
||||
DailyDepositSchemeProduct.effectiveTo ||
|
||||
DailyDepositSchemeProduct.introducerRequired !== undefined ||
|
||||
DailyDepositSchemeProduct.shareLinkingEnabled !== undefined
|
||||
) {
|
||||
<h3 class="mat-h3 flex-100">Product Classification</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
@if (DailyDepositSchemeProduct.accountType) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Account Type:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.accountType }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.glAccountId) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">GL Account (Liability):</span>
|
||||
<span class="flex-60">{{ getGlAccountName(DailyDepositSchemeProduct.glAccountId) }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.effectiveTo) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Effective To:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.effectiveTo | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.endDate) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">End Date:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.endDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.introducerRequired !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Introducer Required:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.introducerRequired === true ||
|
||||
DailyDepositSchemeProduct.introducerRequired === 'Yes'
|
||||
? 'Yes'
|
||||
: 'No'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.shareLinkingEnabled !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Deposit Account Link:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.shareLinkingEnabled | yesNo }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ── Currency ── -->
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Currency' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.currencyCode
|
||||
| find: DailyDepositSchemeProductsTemplate.currencyOptions : 'code' : 'name'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.digitsAfterDecimal }}</span>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.dormancyAlert !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Dormancy Alert:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.dormancyAlert | yesNo }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.nominationFacility !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Nomination Facility:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.nominationFacility | yesNo }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.autoDebitFacility !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Auto Debit Facility:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.autoDebitFacility | yesNo }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (
|
||||
DailyDepositSchemeProduct.minDepositAmount !== undefined || DailyDepositSchemeProduct.maxDepositAmount !== undefined
|
||||
) {
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
<h3 class="mat-h3 flex-100">Deposit Amount</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
@if (DailyDepositSchemeProduct.minDepositAmount !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Minimum:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.minDepositAmount }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.maxDepositAmount !== undefined) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Maximum:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.maxDepositAmount }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ── Terms ── -->
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Terms' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%):</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.nominalAnnualInterestRate | formatNumber }} %</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.interestCompoundingPeriodType
|
||||
| find: DailyDepositSchemeProductsTemplate.interestCompoundingPeriodTypeOptions : 'id' : 'value'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.interestPostingPeriodType
|
||||
| find: DailyDepositSchemeProductsTemplate.interestPostingPeriodTypeOptions : 'id' : 'value'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.interestCalculateSelector) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Interest Calculation Type' | translate }}:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.interestCalculateSelector
|
||||
| find: DailyDepositSchemeProductsTemplate.interestCalculationTypeOption : 'id' : 'value'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.interestCalculationType
|
||||
| find: DailyDepositSchemeProductsTemplate.interestCalculationTypeOptions : 'id' : 'value'
|
||||
| translateKey: 'catalogs'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span>
|
||||
<span class="flex-60">{{
|
||||
DailyDepositSchemeProduct.interestCalculationDaysInYearType
|
||||
| find: DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearTypeOptions : 'id' : 'value'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.effectiveDate) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Effective Date:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.effectiveDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.averagePeriod) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Average Period:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.averagePeriod }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Auto Interest Post:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.autoPost | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
@if (
|
||||
DailyDepositSchemeProduct.frequencyFixDay !== undefined &&
|
||||
DailyDepositSchemeProduct.frequencyFixDay !== null &&
|
||||
DailyDepositSchemeProduct.frequencyFixDay !== ''
|
||||
) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Frequency (Fix Day):</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.frequencyFixDay }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.lastInterestDate) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Last Interest Date:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.lastInterestDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Settings ── -->
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Settings' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Loan Facility:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.loanFacility | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Premature Withdrawal:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.prematureWithdrawalOption | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Tenure Flexibility:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.tenureFlexibility | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Lien Marked:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.lienMarked | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.withdrawalFeeForTransfers | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Enable Lock-in Period:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.enableLockinPeriod | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.enableLockinPeriod && DailyDepositSchemeProduct.lockinPeriodFrequency) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Lock-in Period:</span>
|
||||
<span class="flex-60">
|
||||
{{ DailyDepositSchemeProduct.lockinPeriodFrequency }}
|
||||
{{ getLockinPeriodFrequencyTypeLabel(DailyDepositSchemeProduct.lockinPeriodFrequencyType) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Maturity Instructions:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.maturityInstructions | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Credit Maturity Proceeds:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.creditMaturityProceeds | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Penalty Grace Period (Missed Installments):</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Panel Request Applicable:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.panelRequestApplicable | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">No Penalty (Nominee / Heir Closure):</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Deceased Settlement:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.deceasedSettlement | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Allow Debit Txn After Due Date:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.allowDebitTxnAfterDueDate | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Account Closed with Zero Balance in Day Closing:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing | yesNo }}</span>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.minBalanceForInterestCalculation) {
|
||||
<div class="flex-100 layout-row">
|
||||
<span class="flex-40">Min Balance for Interest Calculation:</span>
|
||||
<span class="flex-60">{{ DailyDepositSchemeProduct.minBalanceForInterestCalculation | formatNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Interest Rate Slabs ── -->
|
||||
@if (DailyDepositSchemeProduct.rdSlabs?.length) {
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Interest Rate Charts' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
<div class="layout-row-wrap responsive-column flex-100">
|
||||
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="DailyDepositSchemeProduct.rdSlabs">
|
||||
<ng-container matColumnDef="srNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Sr.</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Particular</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.description }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fromPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.fromPeriod }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="toPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? '—' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="periodTypeLabel">
|
||||
<th mat-header-cell *matHeaderCellDef>Period</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.periodTypeLabel }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="annualInterestRate">
|
||||
<th mat-header-cell *matHeaderCellDef>Rate (%)</th>
|
||||
<td mat-cell *matCellDef="let slab">
|
||||
<strong>{{ slab.annualInterestRate }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Charges ── -->
|
||||
@if (DailyDepositSchemeProduct.charges?.length) {
|
||||
<div class="layout-row-wrap responsive-column flex-100">
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Charges' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="DailyDepositSchemeProduct.charges">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.name + ', ' + charge.currency.displaySymbol }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="chargeCalculationType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.chargeCalculationType.value }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.amount | formatNumber }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="chargeTimeType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Collected On' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.chargeTimeType.value }}</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="chargesDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: chargesDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Accounting ── -->
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Accounting' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
@if (isCashOrAccrualAccounting()) {
|
||||
<div class="spa-gl-summary flex-100">
|
||||
<div class="spa-gl-summary__header">
|
||||
<h4 class="mat-h4">GL Mapping</h4>
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Assets</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Savings Reference Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.savingsReferenceAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@if (isAccrualAccounting()) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Fees Receivable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.feesReceivableAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Penalties Receivable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.penaltiesReceivableAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Liabilities</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Savings Control Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.savingsControlAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Transfers In Suspense Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.transfersInSuspenseAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@if (isAccrualAccounting()) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest Payable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.interestPayableAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Expenses</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest on Savings Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.interestOnSavingsAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.expenseAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Income</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Income from Fees</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.incomeFromFeeAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Income from Penalties</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
DailyDepositSchemeProduct.incomeFromPenaltyAccountId,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Other GL Mappings -->
|
||||
@if (DailyDepositSchemeProduct.otherGlMappings) {
|
||||
<div class="spa-other-gl-summary flex-100">
|
||||
<div class="spa-gl-summary__header">
|
||||
<h4 class="mat-h4">Other GL Mappings</h4>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProduct.otherGlMappings.assets?.length) {
|
||||
<table
|
||||
class="spa-other-gl-table flex-100 mat-elevation-z1"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.otherGlMappings.assets"
|
||||
>
|
||||
<ng-container matColumnDef="accountName">
|
||||
<th mat-header-cell *matHeaderCellDef>Asset Account</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="glCode">
|
||||
<th mat-header-cell *matHeaderCellDef>GL Account</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
{{
|
||||
formatGlAccountByCode(
|
||||
row.glCode,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.otherGlMappings.liabilities?.length) {
|
||||
<table
|
||||
class="spa-other-gl-table flex-100 mat-elevation-z1"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.otherGlMappings.liabilities"
|
||||
>
|
||||
<ng-container matColumnDef="accountName">
|
||||
<th mat-header-cell *matHeaderCellDef>Liability Account</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="glCode">
|
||||
<th mat-header-cell *matHeaderCellDef>GL Account</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
{{
|
||||
formatGlAccountByCode(
|
||||
row.glCode,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.otherGlMappings.expenses?.length) {
|
||||
<table
|
||||
class="spa-other-gl-table flex-100 mat-elevation-z1"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.otherGlMappings.expenses"
|
||||
>
|
||||
<ng-container matColumnDef="accountName">
|
||||
<th mat-header-cell *matHeaderCellDef>Expense Account</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="glCode">
|
||||
<th mat-header-cell *matHeaderCellDef>GL Account</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
{{
|
||||
formatGlAccountByCode(
|
||||
row.glCode,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.expenseAccountOptions
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.otherGlMappings.income?.length) {
|
||||
<table
|
||||
class="spa-other-gl-table flex-100 mat-elevation-z1"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.otherGlMappings.income"
|
||||
>
|
||||
<ng-container matColumnDef="accountName">
|
||||
<th mat-header-cell *matHeaderCellDef>Income Account</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="glCode">
|
||||
<th mat-header-cell *matHeaderCellDef>GL Account</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
{{
|
||||
formatGlAccountByCode(
|
||||
row.glCode,
|
||||
DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Advanced Accounting Rules -->
|
||||
@if (
|
||||
DailyDepositSchemeProduct.advancedAccountingRules &&
|
||||
(DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length ||
|
||||
DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length ||
|
||||
DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length)
|
||||
) {
|
||||
<div class="layout-row-wrap responsive-column flex-100">
|
||||
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Advanced Accounting Rules' | translate }}</h3>
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
|
||||
@if (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length) {
|
||||
<div class="flex-100 layout-row-wrap responsive-column">
|
||||
<h4 class="mat-h4 flex-100">{{ 'labels.heading.Fund Sources for Payment Channels' | translate }}</h4>
|
||||
<table
|
||||
class="mat-elevation-z1 flex-100"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.paymentChannelToFundSourceMappings"
|
||||
>
|
||||
<ng-container matColumnDef="paymentTypeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let item">
|
||||
{{ item.paymentTypeId | find: DailyDepositSchemeProductsTemplate.paymentTypeOptions : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fundSourceAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let item">
|
||||
{{
|
||||
item.fundSourceAccountId
|
||||
| find
|
||||
: DailyDepositSchemeProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
: 'id'
|
||||
: 'name'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length) {
|
||||
<div class="flex-100 layout-row-wrap responsive-column">
|
||||
<h4 class="mat-h4 flex-100">{{ 'labels.heading.Fees to Specific Income Accounts' | translate }}</h4>
|
||||
<table
|
||||
class="flex-100 mat-elevation-z1"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.feeToIncomeAccountMappings"
|
||||
>
|
||||
<ng-container matColumnDef="chargeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let item">
|
||||
{{ item.chargeId | find: DailyDepositSchemeProductsTemplate.chargeOptions : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incomeAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let item">
|
||||
{{
|
||||
item.incomeAccountId
|
||||
| find
|
||||
: DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions
|
||||
: 'id'
|
||||
: 'name'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) {
|
||||
<div class="flex-100 layout-row-wrap responsive-column">
|
||||
<h4 class="mat-h4 flex-100">{{ 'labels.heading.Penalties to Specific Income Accounts' | translate }}</h4>
|
||||
<table
|
||||
class="flex-100 mat-elevation-z1"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.penaltyToIncomeAccountMappings"
|
||||
>
|
||||
<ng-container matColumnDef="chargeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let item">
|
||||
{{ item.chargeId | find: DailyDepositSchemeProductsTemplate.penaltyOptions : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incomeAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let item">
|
||||
{{
|
||||
item.incomeAccountId
|
||||
| find
|
||||
: DailyDepositSchemeProductsTemplate?.accountingMappingOptions.incomeAccountOptions
|
||||
: 'id'
|
||||
: 'name'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use 'assets/styles/colours' as *;
|
||||
|
||||
.mat-h3 {
|
||||
margin-top: 10px !important;
|
||||
font-weight: 300;
|
||||
color: $status-approved;
|
||||
}
|
||||
|
||||
.mat-elevation-z1 {
|
||||
margin: 1em 0 1.5em;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
span {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
mat-divider {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
/* ── GL mapping summary (same as savings product preview) ── */
|
||||
.spa-gl-summary,
|
||||
.spa-other-gl-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.spa-gl-summary__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.spa-gl-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.15rem;
|
||||
border: 1px solid #e3e7f2;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
|
||||
h5 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #91a0bc;
|
||||
}
|
||||
}
|
||||
|
||||
.spa-gl-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 40%) 1fr;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.spa-gl-row__label {
|
||||
color: #1f2f4a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spa-gl-row__value {
|
||||
color: #101828;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.spa-other-gl-table {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
th.mat-mdc-header-cell,
|
||||
td.mat-mdc-cell {
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
th.mat-mdc-header-cell:first-child,
|
||||
td.mat-mdc-cell:first-child {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
th.mat-mdc-header-cell:last-child,
|
||||
td.mat-mdc-cell:last-child {
|
||||
width: 62%;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.spa-other-gl-summary .mat-elevation-z1 {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.spa-gl-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { Accounting } from 'app/core/utils/accounting';
|
||||
import { OptionData } from 'app/shared/models/option-data.model';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { FindPipe } from '../../../../pipes/find.pipe';
|
||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-preview-step',
|
||||
templateUrl: './daily-deposit-scheme-product-preview-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-preview-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDivider,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
FaIconComponent,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
FindPipe,
|
||||
DateFormatPipe,
|
||||
FormatNumberPipe,
|
||||
YesnoPipe
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductPreviewStepComponent implements OnInit, OnChanges {
|
||||
private accounting = inject(Accounting);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
@Input() chartSlabsDisplayedColumns: any[] = [];
|
||||
@Input() accountingRuleData: any;
|
||||
@Input() DailyDepositSchemeProduct: any;
|
||||
@Output() submitEvent = new EventEmitter();
|
||||
|
||||
chargesDisplayedColumns: string[] = [
|
||||
'name',
|
||||
'chargeCalculationType',
|
||||
'amount',
|
||||
'chargeTimeType'
|
||||
];
|
||||
paymentFundSourceDisplayedColumns: string[] = [
|
||||
'paymentTypeId',
|
||||
'fundSourceAccountId'
|
||||
];
|
||||
feesPenaltyIncomeDisplayedColumns: string[] = [
|
||||
'chargeId',
|
||||
'incomeAccountId'
|
||||
];
|
||||
|
||||
readonly rdSlabColumns = [
|
||||
'srNo',
|
||||
'description',
|
||||
'fromPeriod',
|
||||
'toPeriod',
|
||||
'periodTypeLabel',
|
||||
'annualInterestRate'
|
||||
];
|
||||
|
||||
accountingRule: OptionData | undefined;
|
||||
|
||||
ngOnInit() {
|
||||
this.setCurrentValues();
|
||||
}
|
||||
|
||||
ngOnChanges(_changes: SimpleChanges): void {
|
||||
this.setCurrentValues();
|
||||
}
|
||||
|
||||
setCurrentValues(): void {
|
||||
if (this.DailyDepositSchemeProduct) {
|
||||
this.accountingRule = this.accounting.getAccountingRuleFrom(this.DailyDepositSchemeProduct.accountingRule);
|
||||
}
|
||||
}
|
||||
|
||||
isNoneAccounting(): boolean {
|
||||
return this.accounting.isNoneAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule);
|
||||
}
|
||||
|
||||
isCashOrAccrualAccounting(): boolean {
|
||||
return this.accounting.isCashOrAccrualAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule);
|
||||
}
|
||||
|
||||
isAccrualAccounting(): boolean {
|
||||
return this.accounting.isAccrualAccountingRuleId(this.DailyDepositSchemeProduct.accountingRule);
|
||||
}
|
||||
|
||||
getLockinPeriodFrequencyTypeLabel(value: any): string {
|
||||
if (!value && value !== 0) return '';
|
||||
const options = this.DailyDepositSchemeProductsTemplate?.lockinPeriodFrequencyTypeOptions || [];
|
||||
const found = options.find((o: any) => o.id === value);
|
||||
return found?.value || String(value);
|
||||
}
|
||||
|
||||
getGlAccountName(id: any): string {
|
||||
if (!id) return '';
|
||||
const accounts = this.DailyDepositSchemeProductsTemplate?.accountingMappingOptions?.liabilityAccountOptions ?? [];
|
||||
const found = accounts.find((a: any) => a.id === id || a.id === +id);
|
||||
return found ? `${found.name} (${found.glCode})` : String(id);
|
||||
}
|
||||
|
||||
formatGlAccountById(glAccountId: number, options: any[]): string {
|
||||
if (!glAccountId || !Array.isArray(options)) return '-';
|
||||
const account = this.accounting.glAccountLookUp(glAccountId, options as any);
|
||||
return account ? `(${account.glCode}) ${account.name}` : '-';
|
||||
}
|
||||
|
||||
formatGlAccountByCode(glCode: string, options: any[]): string {
|
||||
if (!glCode || !Array.isArray(options)) return glCode || '-';
|
||||
const account = options.find((o: any) => o?.glCode === glCode);
|
||||
return account ? `(${account.glCode}) ${account.name}` : glCode;
|
||||
}
|
||||
}
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="DailyDepositSchemeProductSettingsForm">
|
||||
<!-- Premature option -->
|
||||
<div class="rdps-section">
|
||||
<div class="rdps-section__header">
|
||||
<span class="rdps-section__badge"><i class="fas fa-balance-scale"></i></span>
|
||||
<span>Premature option</span>
|
||||
</div>
|
||||
<div class="rdps-body">
|
||||
<div class="rdps-premature-row">
|
||||
<mat-checkbox formControlName="loanFacility">Loan Facility</mat-checkbox>
|
||||
<mat-checkbox formControlName="prematureWithdrawalOption">Premature Withdrawal option</mat-checkbox>
|
||||
<mat-checkbox formControlName="tenureFlexibility">Tenure Flexibility</mat-checkbox>
|
||||
<mat-checkbox formControlName="lienMarked">Lien Marked</mat-checkbox>
|
||||
<mat-checkbox formControlName="withdrawalFeeForTransfers">Apply Withdrawal Fee for Transfers</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lock-in Period -->
|
||||
<div class="rdps-section">
|
||||
<div class="rdps-section__header">
|
||||
<span class="rdps-section__badge"><i class="fas fa-lock"></i></span>
|
||||
<span>{{ 'labels.inputs.Lock-in Period' | translate }}</span>
|
||||
</div>
|
||||
<div class="rdps-body">
|
||||
<div class="rdps-lockin-row">
|
||||
<span>Enable Lock-in Period</span>
|
||||
<mat-checkbox formControlName="enableLockinPeriod"></mat-checkbox>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProductSettingsForm.value.enableLockinPeriod) {
|
||||
<div class="rdps-lockin-fields">
|
||||
<mat-form-field appearance="outline" class="rdps-field">
|
||||
<mat-label>Frequency*</mat-label>
|
||||
<input type="number" matInput formControlName="lockinPeriodFrequency" min="1" step="1" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdps-field">
|
||||
<mat-label>Type*</mat-label>
|
||||
<mat-select formControlName="lockinPeriodFrequencyType">
|
||||
@for (lockinPeriodFrequencyType of lockinPeriodFrequencyTypeData; track lockinPeriodFrequencyType) {
|
||||
<mat-option [value]="lockinPeriodFrequencyType.id">
|
||||
{{ lockinPeriodFrequencyType.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maturity Instructions -->
|
||||
<div class="rdps-section">
|
||||
<div class="rdps-section__header">
|
||||
<span class="rdps-section__badge"><i class="fas fa-lock"></i></span>
|
||||
<span>Maturity Instructions</span>
|
||||
</div>
|
||||
<div class="rdps-body rdps-body--list">
|
||||
<div class="rdps-list-row">
|
||||
<span>Maturity Instructions</span>
|
||||
<mat-checkbox formControlName="maturityInstructions"></mat-checkbox>
|
||||
</div>
|
||||
<div class="rdps-list-row">
|
||||
<span
|
||||
>Option to credit maturity proceeds directly to the linked account or reinvest in another product (like
|
||||
FD).</span
|
||||
>
|
||||
<mat-checkbox formControlName="creditMaturityProceeds"></mat-checkbox>
|
||||
</div>
|
||||
<div class="rdps-list-row">
|
||||
<span>Penalty / Grace Period for Missed Installments</span>
|
||||
<mat-checkbox formControlName="penaltyGracePeriodMissedInstallments"></mat-checkbox>
|
||||
</div>
|
||||
<div class="rdps-list-row">
|
||||
<span>Panel Request Applicable</span>
|
||||
<mat-checkbox formControlName="panelRequestApplicable"></mat-checkbox>
|
||||
</div>
|
||||
<div class="rdps-list-row">
|
||||
<span>No penalty for nominee / legal heir closure</span>
|
||||
<mat-checkbox formControlName="noPenaltyNomineeHeirClosure"></mat-checkbox>
|
||||
</div>
|
||||
<div class="rdps-list-row">
|
||||
<span>Deceased Settlement</span>
|
||||
<mat-checkbox formControlName="deceasedSettlement"></mat-checkbox>
|
||||
</div>
|
||||
<div class="rdps-list-row">
|
||||
<span>Allow Debit Txn after due date</span>
|
||||
<mat-checkbox formControlName="allowDebitTxnAfterDueDate"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Rules -->
|
||||
<div class="rdps-section">
|
||||
<div class="rdps-section__header">
|
||||
<span class="rdps-section__badge"><i class="fas fa-cog"></i></span>
|
||||
<span>Additional Rules</span>
|
||||
</div>
|
||||
<div class="rdps-body">
|
||||
<div class="rdps-layout rdps-layout--additional">
|
||||
<div class="rdps-toggle rdps-toggle--inline">
|
||||
<mat-checkbox formControlName="accountClosedZeroBalanceInDayClosing">
|
||||
<span>Account Closed with Zero balance in Day Closing</span>
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdps-field">
|
||||
<mat-label>Balance Required for Interest Calculation</mat-label>
|
||||
<input type="number" matInput formControlName="minBalanceForInterestCalculation" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$accent: #0ea5e9;
|
||||
$border: #e2e8f0;
|
||||
$gray-50: #f8f9fa;
|
||||
$gray-200: #e8eaed;
|
||||
$gray-900: #202124;
|
||||
|
||||
.rdps-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
& + & {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
span:not(.rdps-section__badge) {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdps-section__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rdps-body {
|
||||
background: #fff;
|
||||
padding: 18px 24px 24px;
|
||||
}
|
||||
|
||||
.rdps-field {
|
||||
width: 100%;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.rdps-premature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
mat-checkbox {
|
||||
font-size: 14px;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
.rdps-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rdps-layout--additional {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rdps-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
|
||||
mat-checkbox {
|
||||
font-size: 14px;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
&--full {
|
||||
min-height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdps-body--list {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.rdps-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
font-size: 14px;
|
||||
color: $gray-900;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
span {
|
||||
max-width: 85%;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.rdps-lockin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
font-size: 14px;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.rdps-lockin-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
&--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
&--single {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-settings-step',
|
||||
templateUrl: './daily-deposit-scheme-product-settings-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-settings-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatCheckbox
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductSettingsStepComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
|
||||
DailyDepositSchemeProductSettingsForm: UntypedFormGroup;
|
||||
|
||||
lockinPeriodFrequencyTypeData: any;
|
||||
|
||||
constructor() {
|
||||
this.createDailyDepositSchemeProductSettingsForm();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.lockinPeriodFrequencyTypeData = this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequencyTypeOptions;
|
||||
|
||||
if (this.DailyDepositSchemeProductsTemplate?.id) {
|
||||
this.DailyDepositSchemeProductSettingsForm.patchValue({
|
||||
loanFacility: this.DailyDepositSchemeProductsTemplate.loanFacility ?? false,
|
||||
prematureWithdrawalOption: this.DailyDepositSchemeProductsTemplate.prematureWithdrawalOption ?? false,
|
||||
tenureFlexibility: this.DailyDepositSchemeProductsTemplate.tenureFlexibility ?? false,
|
||||
lienMarked: this.DailyDepositSchemeProductsTemplate.lienMarked ?? false,
|
||||
withdrawalFeeForTransfers: this.DailyDepositSchemeProductsTemplate.withdrawalFeeForTransfers ?? false,
|
||||
enableLockinPeriod: this.DailyDepositSchemeProductsTemplate.enableLockinPeriod ?? false,
|
||||
lockinPeriodFrequency: this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequency ?? '',
|
||||
lockinPeriodFrequencyType: this.DailyDepositSchemeProductsTemplate.lockinPeriodFrequencyType?.id ?? '',
|
||||
maturityInstructions: this.DailyDepositSchemeProductsTemplate.maturityInstructions ?? false,
|
||||
creditMaturityProceeds: this.DailyDepositSchemeProductsTemplate.creditMaturityProceeds ?? false,
|
||||
penaltyGracePeriodMissedInstallments:
|
||||
this.DailyDepositSchemeProductsTemplate.penaltyGracePeriodMissedInstallments ?? false,
|
||||
panelRequestApplicable: this.DailyDepositSchemeProductsTemplate.panelRequestApplicable ?? false,
|
||||
noPenaltyNomineeHeirClosure: this.DailyDepositSchemeProductsTemplate.noPenaltyNomineeHeirClosure ?? false,
|
||||
deceasedSettlement: this.DailyDepositSchemeProductsTemplate.deceasedSettlement ?? false,
|
||||
allowDebitTxnAfterDueDate: this.DailyDepositSchemeProductsTemplate.allowDebitTxnAfterDueDate ?? false,
|
||||
accountClosedZeroBalanceInDayClosing:
|
||||
this.DailyDepositSchemeProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false,
|
||||
minBalanceForInterestCalculation: this.DailyDepositSchemeProductsTemplate.minBalanceForInterestCalculation ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createDailyDepositSchemeProductSettingsForm() {
|
||||
this.DailyDepositSchemeProductSettingsForm = this.formBuilder.group({
|
||||
loanFacility: [false],
|
||||
prematureWithdrawalOption: [false],
|
||||
tenureFlexibility: [false],
|
||||
lienMarked: [false],
|
||||
withdrawalFeeForTransfers: [false],
|
||||
enableLockinPeriod: [false],
|
||||
lockinPeriodFrequency: [''],
|
||||
lockinPeriodFrequencyType: [''],
|
||||
maturityInstructions: [false],
|
||||
creditMaturityProceeds: [false],
|
||||
penaltyGracePeriodMissedInstallments: [false],
|
||||
panelRequestApplicable: [false],
|
||||
noPenaltyNomineeHeirClosure: [false],
|
||||
deceasedSettlement: [false],
|
||||
allowDebitTxnAfterDueDate: [false],
|
||||
accountClosedZeroBalanceInDayClosing: [false],
|
||||
minBalanceForInterestCalculation: ['']
|
||||
});
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductSettings() {
|
||||
const settings = { ...this.DailyDepositSchemeProductSettingsForm.value };
|
||||
for (const key in settings) {
|
||||
if (settings[key] === '') {
|
||||
delete settings[key];
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="DailyDepositSchemeProductTermsForm">
|
||||
<div class="rdpt-section">
|
||||
<div class="rdpt-section__header">
|
||||
<span class="rdpt-section__badge"><i class="fas fa-coins"></i></span>
|
||||
<span>Deposit Amount</span>
|
||||
</div>
|
||||
<div class="rdpt-body">
|
||||
<div class="rdpt-grid rdpt-grid--three">
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Minimum</mat-label>
|
||||
<input type="number" matInput formControlName="minDepositAmount" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Maximum</mat-label>
|
||||
<input type="number" matInput formControlName="maxDepositAmount" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rdpt-section">
|
||||
<div class="rdpt-section__header">
|
||||
<span class="rdpt-section__badge"><i class="fas fa-chart-line"></i></span>
|
||||
<span>Interest Rate & Posting Terms</span>
|
||||
</div>
|
||||
<div class="rdpt-body">
|
||||
<div class="rdpt-banner">
|
||||
<span class="rdpt-banner__icon">ℹ️</span>
|
||||
<span>
|
||||
Daily Deposit Scheme base rate:
|
||||
<b>{{ DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').value }}%</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rdpt-grid rdpt-grid--three">
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>{{ 'labels.inputs.Nominal Annual Interest' | translate }}</mat-label>
|
||||
<input
|
||||
type="number"
|
||||
matInput
|
||||
matTooltip="{{ 'tooltips.The default interest rate set' | translate }}"
|
||||
formControlName="nominalAnnualInterestRate"
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
@if (DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Nominal Annual Interest' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
@if (DailyDepositSchemeProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) {
|
||||
<mat-error>{{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Effective Date</mat-label>
|
||||
<input matInput [matDatepicker]="effectiveDatePicker" formControlName="effectiveDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="effectiveDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #effectiveDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>{{ 'labels.inputs.Interest Calculation Type' | translate }}</mat-label>
|
||||
<mat-select
|
||||
formControlName="interestCalculateSelector"
|
||||
matTooltip="{{ 'tooltips.The method used to calculate interest' | translate }}"
|
||||
>
|
||||
@for (opt of interestCalculationTypeOptionData; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.value }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="rdpt-grid rdpt-grid--three">
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Interest Calculate</mat-label>
|
||||
<mat-select formControlName="interestCalculationType" required>
|
||||
@for (mode of interestCalculationTypeData; track mode.id) {
|
||||
<mat-option [value]="mode.id">{{ mode.value | translateKey: 'catalogs' }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Interest Calculation Type' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Interest Compounding Period</mat-label>
|
||||
<mat-select formControlName="interestCompoundingPeriodType" required>
|
||||
@for (opt of interestCompoundingPeriodTypeData; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.value | translateKey: 'catalogs' }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>Interest Compounding Period <strong>is required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Days in Year</mat-label>
|
||||
<mat-select formControlName="interestCalculationDaysInYearType" required>
|
||||
@for (opt of interestCalculationDaysInYearTypeData; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.value | translateKey: 'catalogs' }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>Days in Year <strong>is required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
@if (DailyDepositSchemeProductTermsForm.value.interestCalculationType === 2) {
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Average Period</mat-label>
|
||||
<mat-select formControlName="averagePeriod">
|
||||
@for (period of periodFrequencyData; track period.id) {
|
||||
<mat-option [value]="period.value">{{ period.value }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>{{ 'labels.inputs.Interest Posting Period' | translate }}</mat-label>
|
||||
<mat-select
|
||||
formControlName="interestPostingPeriodType"
|
||||
matTooltip="{{ 'tooltips.Interest rate is posted or credited to a saving account' | translate }}"
|
||||
required
|
||||
>
|
||||
@for (interestPostingPeriodType of interestPostingPeriodTypeData; track interestPostingPeriodType) {
|
||||
<mat-option [value]="interestPostingPeriodType.id">
|
||||
{{ interestPostingPeriodType.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Interest Posting Period' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="rdpt-footer">
|
||||
<div class="rdpt-footer__grid">
|
||||
<div class="rdpt-toggle-card">
|
||||
<div>
|
||||
<h4>Auto Interest Post</h4>
|
||||
</div>
|
||||
<div class="rdpt-toggle-card__choices">
|
||||
@for (option of yesNoOptions; track option.value) {
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input type="radio" class="w-4 h-4" formControlName="autoPost" [value]="option.value" />
|
||||
<span class="ml-2 text-sm">{{ option.label }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Frequency (Fix Day)</mat-label>
|
||||
<input type="number" matInput formControlName="frequencyFixDay" />
|
||||
<mat-hint>Days</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rdpt-field">
|
||||
<mat-label>Last Interest Date</mat-label>
|
||||
<input matInput [matDatepicker]="lastInterestDatePicker" formControlName="lastInterestDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="lastInterestDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #lastInterestDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (DailyDepositSchemeProductTermsForm.value.interestCalculationType === 1) {
|
||||
<div class="rdpt-note">
|
||||
<span>⚠️</span>
|
||||
<span
|
||||
>Daily product-based interest accrual enabled - interest calculated EOD on daily closing balance.</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$accent: #0ea5e9;
|
||||
$border: #e2e8f0;
|
||||
$gray-50: #f8f9fa;
|
||||
$gray-200: #e8eaed;
|
||||
$gray-900: #202124;
|
||||
|
||||
.rdpt-section {
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
& + & {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
h3,
|
||||
span:not(.rdpt-section__badge) {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdpt-section__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rdpt-body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rdpt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
padding: 16px 24px;
|
||||
|
||||
&--three {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
&--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdpt-field {
|
||||
width: 100%;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpt-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 16px 24px 0;
|
||||
padding: 12px 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
|
||||
&__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpt-footer {
|
||||
padding: 0 24px 24px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.rdpt-footer__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding-top: 18px;
|
||||
|
||||
@media (width <= 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpt-toggle-card {
|
||||
padding: 12px 0;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
&__choices {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rdpt-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-product-terms-step',
|
||||
templateUrl: './daily-deposit-scheme-product-terms-step.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-product-terms-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductTermsStepComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dateUtils = inject(Dates);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
@Input() DailyDepositSchemeProductsTemplate: any;
|
||||
|
||||
DailyDepositSchemeProductTermsForm: UntypedFormGroup;
|
||||
|
||||
interestCompoundingPeriodTypeData: any;
|
||||
interestPostingPeriodTypeData: any;
|
||||
interestCalculationTypeData: any;
|
||||
interestCalculationTypeOptionData: any;
|
||||
interestCalculationDaysInYearTypeData: any;
|
||||
periodFrequencyData: any[] = [];
|
||||
yesNoOptions: Array<{ value: boolean; label: string }> = [
|
||||
{ value: true, label: 'Yes' },
|
||||
{ value: false, label: 'No' }
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.createDailyDepositSchemeProductTermsForm();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.interestCompoundingPeriodTypeData =
|
||||
this.DailyDepositSchemeProductsTemplate.interestCompoundingPeriodTypeOptions;
|
||||
this.interestPostingPeriodTypeData = this.DailyDepositSchemeProductsTemplate.interestPostingPeriodTypeOptions;
|
||||
// Standard enum (1=Daily, 2=Average) → "Interest Calculate" dropdown → interestCalculationType form control
|
||||
this.interestCalculationTypeData = this.DailyDepositSchemeProductsTemplate.interestCalculationTypeOptions;
|
||||
// CBS code values → "Interest Calculation Type" dropdown → interestCalculateSelector form control
|
||||
this.interestCalculationTypeOptionData =
|
||||
this.DailyDepositSchemeProductsTemplate.interestCalculationTypeOption || [];
|
||||
this.interestCalculationDaysInYearTypeData =
|
||||
this.DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearTypeOptions;
|
||||
this.periodFrequencyData = this.DailyDepositSchemeProductsTemplate.periodFrequencyOptions || [];
|
||||
this.setYesNoOptions(this.DailyDepositSchemeProductsTemplate?.yesNoOptions);
|
||||
|
||||
const isEditing = !!this.DailyDepositSchemeProductsTemplate.id;
|
||||
const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly';
|
||||
const savedEnumTypeId =
|
||||
this.DailyDepositSchemeProductsTemplate.interestCalculationType?.id ||
|
||||
this.interestCalculationTypeData?.[0]?.id ||
|
||||
1;
|
||||
const savedSelectorId = isEditing
|
||||
? (this.DailyDepositSchemeProductsTemplate.interestCalculateSelector ??
|
||||
this.interestCalculationTypeOptionData?.[0]?.id ??
|
||||
null)
|
||||
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
|
||||
|
||||
this.DailyDepositSchemeProductTermsForm.patchValue({
|
||||
nominalAnnualInterestRate: this.DailyDepositSchemeProductsTemplate.nominalAnnualInterestRate ?? 0,
|
||||
interestCompoundingPeriodType: this.DailyDepositSchemeProductsTemplate.interestCompoundingPeriodType?.id,
|
||||
interestPostingPeriodType: this.DailyDepositSchemeProductsTemplate.interestPostingPeriodType?.id,
|
||||
interestCalculationType: savedEnumTypeId,
|
||||
interestCalculationDaysInYearType: this.DailyDepositSchemeProductsTemplate.interestCalculationDaysInYearType?.id,
|
||||
effectiveDate: this.DailyDepositSchemeProductsTemplate.effectiveDate
|
||||
? this.parseDate(this.DailyDepositSchemeProductsTemplate.effectiveDate)
|
||||
: '',
|
||||
interestCalculateSelector: savedSelectorId,
|
||||
averagePeriod: isEditing ? this.DailyDepositSchemeProductsTemplate.averagePeriod || defaultPeriod : defaultPeriod,
|
||||
autoPost: isEditing ? (this.DailyDepositSchemeProductsTemplate.autoPost ?? true) : true,
|
||||
frequencyFixDay: isEditing ? (this.DailyDepositSchemeProductsTemplate.frequencyFixDay ?? 30) : 30,
|
||||
lastInterestDate: this.DailyDepositSchemeProductsTemplate.lastInterestDate
|
||||
? this.parseDate(this.DailyDepositSchemeProductsTemplate.lastInterestDate)
|
||||
: '',
|
||||
minDepositAmount: this.DailyDepositSchemeProductsTemplate.minDepositAmount ?? '',
|
||||
maxDepositAmount: this.DailyDepositSchemeProductsTemplate.maxDepositAmount ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
private parseDate(dateValue: any): Date | null {
|
||||
if (!dateValue) return null;
|
||||
if (Array.isArray(dateValue)) {
|
||||
return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]);
|
||||
}
|
||||
if (dateValue instanceof Date) return dateValue;
|
||||
return new Date(dateValue);
|
||||
}
|
||||
|
||||
private setYesNoOptions(options: any[]) {
|
||||
if (!Array.isArray(options)) return;
|
||||
const mapped = options
|
||||
.map((opt: any) => {
|
||||
const raw = opt?.name ?? opt?.value ?? opt;
|
||||
const norm = typeof raw === 'string' ? raw.trim().toUpperCase() : raw;
|
||||
if (norm === 'YES') return { value: true, label: 'Yes' };
|
||||
if (norm === 'NO') return { value: false, label: 'No' };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (mapped.length) this.yesNoOptions = mapped;
|
||||
}
|
||||
|
||||
createDailyDepositSchemeProductTermsForm() {
|
||||
this.DailyDepositSchemeProductTermsForm = this.formBuilder.group({
|
||||
nominalAnnualInterestRate: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
interestCompoundingPeriodType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
interestPostingPeriodType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
interestCalculationType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
interestCalculationDaysInYearType: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
effectiveDate: [''],
|
||||
interestCalculateSelector: [1],
|
||||
averagePeriod: ['Monthly'],
|
||||
autoPost: [true],
|
||||
frequencyFixDay: [
|
||||
30,
|
||||
[Validators.min(0)]
|
||||
],
|
||||
lastInterestDate: [''],
|
||||
minDepositAmount: [''],
|
||||
maxDepositAmount: ['']
|
||||
});
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductTerms() {
|
||||
const formValue = { ...this.DailyDepositSchemeProductTermsForm.value };
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
if (formValue.effectiveDate instanceof Date) {
|
||||
formValue.effectiveDate = this.dateUtils.formatDate(formValue.effectiveDate, dateFormat) || '';
|
||||
}
|
||||
formValue.minDepositAmount = formValue.minDepositAmount;
|
||||
formValue.maxDepositAmount = formValue.maxDepositAmount;
|
||||
return formValue;
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProductsService } from '../products.service';
|
||||
|
||||
/**
|
||||
* Daily Deposit Scheme Product data resolver.
|
||||
*
|
||||
* Reuses the Recurring Deposit product read API — a Daily Deposit Scheme
|
||||
* product is a Recurring Deposit product under the hood.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DailyDepositSchemeProductResolver {
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
/**
|
||||
* Returns the Daily Deposit Scheme product data.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<any> {
|
||||
const productId = route.parent.paramMap.get('productId');
|
||||
return this.productsService.getRecurringDepositProduct(productId);
|
||||
}
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pl-root">
|
||||
<div class="pl-header">
|
||||
<div class="pl-header-left">
|
||||
<div class="pl-header-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div class="pl-header-text">
|
||||
<h2>{{ 'labels.heading.Daily Deposit Scheme Products' | translate }}</h2>
|
||||
<p>Manage Daily Deposit Scheme products — daily instalment amount, interest rates and charges.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-header-right">
|
||||
<div class="pl-count-chip" matTooltip="Total configured Daily Deposit Scheme products">
|
||||
<fa-icon icon="layer-group"></fa-icon>
|
||||
<strong>{{ totalCount }}</strong>
|
||||
<span>{{ totalCount === 1 ? 'product' : 'products' }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="pl-create-btn"
|
||||
type="button"
|
||||
[routerLink]="['create']"
|
||||
*mifosxHasPermission="'CREATE_RECURRINGDEPOSITPRODUCT'"
|
||||
matTooltip="{{ 'tooltips.Create Daily Deposit Scheme Product' | translate }}"
|
||||
>
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>{{ 'labels.buttons.Create Daily Deposit Scheme Product' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pl-kpis">
|
||||
<div class="pl-kpi pl-kpi--blue">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalCount | number }}</div>
|
||||
<div class="pl-kpi-l">Total DDS Products</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--teal">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="coins"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ currencyCount | number }}</div>
|
||||
<div class="pl-kpi-l">{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--purple">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="filter"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ filteredCount | number }}</div>
|
||||
<div class="pl-kpi-l">Showing after filter</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pl-card">
|
||||
<div class="pl-toolbar">
|
||||
<div class="pl-search">
|
||||
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
||||
<input
|
||||
#filter
|
||||
type="text"
|
||||
(input)="applyFilter($any($event.target).value)"
|
||||
placeholder="Search by name or short name…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totalCount === 0) {
|
||||
<div class="pl-empty">
|
||||
<div class="pl-empty-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<h3>No Daily Deposit Scheme products yet</h3>
|
||||
<p>Create your first Daily Deposit Scheme product to enable daily-instalment onboarding.</p>
|
||||
<button
|
||||
class="pl-create-btn"
|
||||
type="button"
|
||||
[routerLink]="['create']"
|
||||
*mifosxHasPermission="'CREATE_RECURRINGDEPOSITPRODUCT'"
|
||||
>
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>{{ 'labels.buttons.Create Daily Deposit Scheme Product' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pl-table-wrap">
|
||||
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<div class="pl-product-cell">
|
||||
<div class="pl-product-icon"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ p.name }}</div>
|
||||
@if (p.currency?.code) {
|
||||
<div class="pl-product-sub">{{ p.currency.code }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="shortName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Short Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<span class="pl-tag">{{ p.shortName }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
<th mat-header-cell *matHeaderCellDef>Currency</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
@if (p.currency?.code) {
|
||||
<span class="pl-currency-pill">{{ p.currency.code }}</span>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
|
||||
<td mat-cell *matCellDef="let p" class="num">
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn pl-action-btn--view"
|
||||
[routerLink]="[p.id]"
|
||||
(click)="$event.stopPropagation()"
|
||||
matTooltip="View product details"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon><span>View</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn pl-action-btn--edit"
|
||||
[class.pl-action-btn--disabled]="p.isPendingApproval"
|
||||
[disabled]="p.isPendingApproval"
|
||||
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
|
||||
(click)="$event.stopPropagation()"
|
||||
[matTooltip]="
|
||||
p.isPendingApproval
|
||||
? 'This product is locked because an edit is pending approval'
|
||||
: 'Edit this product'
|
||||
"
|
||||
*mifosxHasPermission="'UPDATE_RECURRINGDEPOSITPRODUCT'"
|
||||
>
|
||||
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
|
||||
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
|
||||
</table>
|
||||
|
||||
@if (filteredCount === 0 && totalCount > 0) {
|
||||
<div class="pl-no-match">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<span>No products match your filter.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use 'assets/styles/product-list' as pl;
|
||||
|
||||
@include pl.product-list-styles;
|
||||
|
||||
/* Daily Deposit Scheme accent */
|
||||
:host {
|
||||
--pl-accent: #0f766e;
|
||||
--pl-accent-l: #ccfbf1;
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Daily Deposit Scheme Products component.
|
||||
*
|
||||
* Lists Daily Deposit Scheme products. These are Recurring Deposit products
|
||||
* under the hood (same create/read API) so this list reuses that data source.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-products',
|
||||
templateUrl: './daily-deposit-scheme-products.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-products.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTooltip,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatSort,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatSortHeader,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeProductsComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
/** Data table data. */
|
||||
productData: any[] = [];
|
||||
/** Columns to be displayed in the Daily Deposit Scheme products table. */
|
||||
displayedColumns: string[] = [
|
||||
'name',
|
||||
'shortName',
|
||||
'currency',
|
||||
'actions'
|
||||
];
|
||||
/** Data source for the table. */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
|
||||
filteredCount = 0;
|
||||
|
||||
get totalCount(): number {
|
||||
return this.productData?.length || 0;
|
||||
}
|
||||
|
||||
get currencyCount(): number {
|
||||
const set = new Set<string>();
|
||||
for (const p of this.productData || []) {
|
||||
if (p?.currency?.code) set.add(p.currency.code);
|
||||
}
|
||||
return set.size;
|
||||
}
|
||||
|
||||
/** Paginator for the table. */
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
/** Sorter for the table. */
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { dailyDepositSchemeProducts: any }) => {
|
||||
this.productData = (data.dailyDepositSchemeProducts || []).filter(
|
||||
(product: any) => product.productCategory === 'DDS'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.setProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters data in the table based on the passed value.
|
||||
* @param {string} filterValue Value to filter data.
|
||||
*/
|
||||
applyFilter(filterValue: string) {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the data source, paginator and sorter for the table.
|
||||
*/
|
||||
setProducts() {
|
||||
this.dataSource = new MatTableDataSource(this.productData);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.filteredCount = this.totalCount;
|
||||
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProductsService } from '../products.service';
|
||||
|
||||
/**
|
||||
* Daily Deposit Scheme Products data resolver.
|
||||
*
|
||||
* A Daily Deposit Scheme product is a Recurring Deposit product under the hood,
|
||||
* so this reuses the same read API as Recurring Deposit Products.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DailyDepositSchemeProductsResolver {
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
/**
|
||||
* Returns the Daily Deposit Scheme products data.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(): Observable<any> {
|
||||
return this.productsService.getRecurringDepositProducts();
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProductsService } from '../../products.service';
|
||||
|
||||
/**
|
||||
* Recurring Deposits Account Template resolver.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DailyDepositSchemeProductAndTemplateResolver {
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
/**
|
||||
* Returns the Recurring Deposits Product and Template.
|
||||
* @param {ActivatedRouteSnapshot} route Route Snapshot
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<any> {
|
||||
const productId = route.parent.paramMap.get('productId');
|
||||
return this.productsService.getRecurringDepositProductAndTemplate(productId);
|
||||
}
|
||||
}
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="rdp-container">
|
||||
<div class="rdp-header">
|
||||
<i class="fas fa-sync-alt rdp-header__icon"></i>
|
||||
<h2 class="rdp-header__title">{{ 'labels.heading.Edit Daily Deposit Scheme Product' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-tab-group
|
||||
class="rdp-tabs"
|
||||
animationDuration="200ms"
|
||||
[preserveContent]="true"
|
||||
[(selectedIndex)]="selectedTabIndex"
|
||||
>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-info-circle rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.DETAILS' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-details-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-details-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-coins rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.CURRENCY' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-currency-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-currency-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-sliders-h rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.TERMS' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-terms-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-terms-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-cog rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.SETTINGS' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-settings-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
></mifosx-daily-deposit-scheme-product-settings-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-chart-area rdp-tab-icon"></i>
|
||||
CBS Base Chart
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
@if (activeBaseVersion) {
|
||||
<div class="base-rate-card">
|
||||
<div class="base-rate-header">
|
||||
<div>
|
||||
<h3 class="base-rate-title">{{ activeBaseVersion.versionLabel }}</h3>
|
||||
<p class="base-rate-meta">
|
||||
Active CBS base chart
|
||||
<span>Effective from {{ activeBaseVersion.effectiveFromDate }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="base-rate-badge">Active</span>
|
||||
</div>
|
||||
|
||||
@if (activeBaseVersion.remarks) {
|
||||
<p class="base-rate-remarks">{{ activeBaseVersion.remarks }}</p>
|
||||
}
|
||||
|
||||
<table class="base-rate-table" mat-table [dataSource]="activeBaseVersion.slabs">
|
||||
<ng-container matColumnDef="srNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Sr.</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Particular</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.description }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fromPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.fromPeriod }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="toPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? 'N/A' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="periodTypeLabel">
|
||||
<th mat-header-cell *matHeaderCellDef>Period Type</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.periodTypeLabel }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="annualInterestRate">
|
||||
<th mat-header-cell *matHeaderCellDef>Annual Rate (%)</th>
|
||||
<td mat-cell *matCellDef="let slab">
|
||||
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Loading CBS base chart...</p>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-receipt rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.CHARGES' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-charges-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
[currencyCode]="DailyDepositSchemeProductCurrencyForm.get('currencyCode')"
|
||||
></mifosx-daily-deposit-scheme-product-charges-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-book rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.ACCOUNTING' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-accounting-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[DailyDepositSchemeProductFormValid]="DailyDepositSchemeProductFormValid"
|
||||
[isDormancyTrackingActive]="DailyDepositSchemeProductCurrencyForm.get('dormancyAlert')"
|
||||
>
|
||||
</mifosx-daily-deposit-scheme-product-accounting-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
@if (DailyDepositSchemeProductFormValid) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-eye rdp-tab-icon"></i>
|
||||
{{ 'labels.inputs.PREVIEW' | translate }}
|
||||
</ng-template>
|
||||
<div class="rdp-tab-content">
|
||||
<mifosx-daily-deposit-scheme-product-preview-step
|
||||
[DailyDepositSchemeProductsTemplate]="DailyDepositSchemeProductsTemplate"
|
||||
[chartSlabsDisplayedColumns]="rdSlabColumns"
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[DailyDepositSchemeProduct]="DailyDepositSchemeProductPreview"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-daily-deposit-scheme-product-preview-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
}
|
||||
</mat-tab-group>
|
||||
|
||||
<div class="rdp-action-bar">
|
||||
<button mat-raised-button [routerLink]="['../../']" class="rdp-action-bar__cancel">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<div class="rdp-action-bar__right">
|
||||
@if (selectedTabIndex > 0) {
|
||||
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
|
||||
</button>
|
||||
}
|
||||
@if (DailyDepositSchemeProductFormValid && selectedTabIndex === totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" (click)="submit()">
|
||||
<i class="fas fa-check" style="margin-right: 8px"></i>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #1353a0;
|
||||
|
||||
.rdp-container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.rdp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__icon {
|
||||
font-size: 20px;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-tabs {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 100px;
|
||||
padding: 0 12px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-tab-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rdp-tab-content {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.base-rate-card {
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.base-rate-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.base-rate-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.base-rate-meta {
|
||||
margin: 6px 0 0;
|
||||
color: #5b6b7f;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.base-rate-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.base-rate-remarks {
|
||||
margin: 0 0 12px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.base-rate-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rdp-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&__cancel {
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
-305
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
/** Custom Components */
|
||||
import { DailyDepositSchemeProductDetailsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-details-step/daily-deposit-scheme-product-details-step.component';
|
||||
import { DailyDepositSchemeProductCurrencyStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-currency-step/daily-deposit-scheme-product-currency-step.component';
|
||||
import { DailyDepositSchemeProductTermsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-terms-step/daily-deposit-scheme-product-terms-step.component';
|
||||
import { DailyDepositSchemeProductSettingsStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-settings-step/daily-deposit-scheme-product-settings-step.component';
|
||||
import { DailyDepositSchemeProductChargesStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-charges-step/daily-deposit-scheme-product-charges-step.component';
|
||||
import { DailyDepositSchemeProductAccountingStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-accounting-step/daily-deposit-scheme-product-accounting-step.component';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Accounting } from 'app/core/utils/accounting';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { DailyDepositSchemeProductPreviewStepComponent } from '../daily-deposit-scheme-product-stepper/daily-deposit-scheme-product-preview-step/daily-deposit-scheme-product-preview-step.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-edit-daily-deposit-scheme-product',
|
||||
templateUrl: './edit-daily-deposit-scheme-product.component.html',
|
||||
styleUrls: ['./edit-daily-deposit-scheme-product.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
DailyDepositSchemeProductDetailsStepComponent,
|
||||
DailyDepositSchemeProductCurrencyStepComponent,
|
||||
DailyDepositSchemeProductTermsStepComponent,
|
||||
DailyDepositSchemeProductSettingsStepComponent,
|
||||
DailyDepositSchemeProductChargesStepComponent,
|
||||
DailyDepositSchemeProductAccountingStepComponent,
|
||||
DailyDepositSchemeProductPreviewStepComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
]
|
||||
})
|
||||
export class EditDailyDepositSchemeProductComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private productsService = inject(ProductsService);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private accounting = inject(Accounting);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
@ViewChild(DailyDepositSchemeProductDetailsStepComponent, { static: true })
|
||||
DailyDepositSchemeProductDetailsStep: DailyDepositSchemeProductDetailsStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductCurrencyStepComponent, { static: true })
|
||||
DailyDepositSchemeProductCurrencyStep: DailyDepositSchemeProductCurrencyStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductTermsStepComponent, { static: true })
|
||||
DailyDepositSchemeProductTermsStep: DailyDepositSchemeProductTermsStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductSettingsStepComponent, { static: true })
|
||||
DailyDepositSchemeProductSettingsStep: DailyDepositSchemeProductSettingsStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductChargesStepComponent, { static: true })
|
||||
DailyDepositSchemeProductChargesStep: DailyDepositSchemeProductChargesStepComponent;
|
||||
@ViewChild(DailyDepositSchemeProductAccountingStepComponent, { static: true })
|
||||
DailyDepositSchemeProductAccountingStep: DailyDepositSchemeProductAccountingStepComponent;
|
||||
|
||||
DailyDepositSchemeProductsTemplate: any;
|
||||
accountingRuleData: string[] = [];
|
||||
rdInterestRates: any[] = [];
|
||||
readonly rdSlabColumns = [
|
||||
'srNo',
|
||||
'description',
|
||||
'fromPeriod',
|
||||
'toPeriod',
|
||||
'periodTypeLabel',
|
||||
'annualInterestRate'
|
||||
];
|
||||
selectedTabIndex = 0;
|
||||
|
||||
/**
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {ProductsService} productsService Products Service.
|
||||
* @param {Router} router Router for navigation.
|
||||
* @param {SettingsService} settingsService Settings Service.
|
||||
*/
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { DailyDepositSchemeProductAndTemplate: any }) => {
|
||||
this.DailyDepositSchemeProductsTemplate = data.DailyDepositSchemeProductAndTemplate;
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => {
|
||||
this.rdInterestRates = rates || [];
|
||||
},
|
||||
error: () => {
|
||||
this.rdInterestRates = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
this.accountingRuleData = this.accounting.getAccountingRulesForSavings();
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductDetailsForm() {
|
||||
return this.DailyDepositSchemeProductDetailsStep?.DailyDepositSchemeProductDetailsForm;
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductCurrencyForm() {
|
||||
return this.DailyDepositSchemeProductCurrencyStep?.DailyDepositSchemeProductCurrencyForm;
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductTermsForm() {
|
||||
return this.DailyDepositSchemeProductTermsStep?.DailyDepositSchemeProductTermsForm;
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductSettingsForm() {
|
||||
return this.DailyDepositSchemeProductSettingsStep?.DailyDepositSchemeProductSettingsForm;
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductAccountingForm() {
|
||||
return this.DailyDepositSchemeProductAccountingStep?.DailyDepositSchemeProductAccountingForm;
|
||||
}
|
||||
|
||||
get totalTabs(): number {
|
||||
return this.DailyDepositSchemeProductFormValid ? 8 : 7;
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductFormValid() {
|
||||
return !!this.DailyDepositSchemeProductDetailsForm?.valid;
|
||||
}
|
||||
|
||||
get activeBaseVersion() {
|
||||
const chart =
|
||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ??
|
||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ??
|
||||
this.rdInterestRates[0];
|
||||
if (chart) {
|
||||
return {
|
||||
...chart,
|
||||
versionLabel: chart.versionLabel?.replace('FD', 'RD'),
|
||||
remarks: chart.remarks?.replace('FD', 'RD')
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get activeBaseSlabs() {
|
||||
return this.activeBaseVersion?.slabs || [];
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProduct() {
|
||||
// accountTypeGroup / autoInterestPost / autoInterestPostWhen are the legacy Account Type
|
||||
// Creation screen's own fields (added for the simplified Create form) - not real product API
|
||||
// parameters, so they must not leak into the update payload the way a plain spread would.
|
||||
// accountTypeGroup is deliberately NOT re-folded into description here (unlike Create): it
|
||||
// was already folded in once at creation time, and this getter re-runs on every change
|
||||
// detection cycle while editing, so re-prepending it here would keep stacking duplicate
|
||||
// "Group - " prefixes onto the same description. autoInterestPost/When aren't re-synced onto
|
||||
// the Terms tab either, since Edit's own Terms/Settings/Accounting tabs remain fully visible
|
||||
// and directly editable.
|
||||
const { accountTypeGroup, autoInterestPost, autoInterestPostWhen, autoInterestPostDay, ...detailsPayload } =
|
||||
this.DailyDepositSchemeProductDetailsStep.DailyDepositSchemeProductDetails;
|
||||
|
||||
return {
|
||||
...detailsPayload,
|
||||
...this.DailyDepositSchemeProductCurrencyStep.DailyDepositSchemeProductCurrency,
|
||||
...this.DailyDepositSchemeProductTermsStep.DailyDepositSchemeProductTerms,
|
||||
...this.DailyDepositSchemeProductSettingsStep.DailyDepositSchemeProductSettings,
|
||||
...this.DailyDepositSchemeProductChargesStep.DailyDepositSchemeProductCharges,
|
||||
...this.DailyDepositSchemeProductAccountingStep.DailyDepositSchemeProductAccounting,
|
||||
// charts intentionally omitted here - the backend no longer requires a charts payload for RD
|
||||
// updates once rdSlabs is present, and this getter's chart data comes from the shared CBS
|
||||
// base-rate template (not this product's own chart), which caused spurious
|
||||
// "chart.overlapping.from.and.end.dates" errors when a product's real chart had different
|
||||
// dates. Still included in DailyDepositSchemeProductPreview below for the read-only display tab.
|
||||
rdSlabs: this.activeBaseSlabs
|
||||
};
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductPreview() {
|
||||
return {
|
||||
...this.DailyDepositSchemeProduct,
|
||||
charts: this.DailyDepositSchemeProductCharts,
|
||||
rdSlabs: this.activeBaseSlabs
|
||||
};
|
||||
}
|
||||
|
||||
get DailyDepositSchemeProductCharts() {
|
||||
const activeBaseVersion = this.activeBaseVersion;
|
||||
if (!activeBaseVersion?.slabs?.length) {
|
||||
return [];
|
||||
}
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
return [
|
||||
{
|
||||
name: activeBaseVersion.versionLabel || 'CBS Base Chart',
|
||||
description: activeBaseVersion.remarks || activeBaseVersion.versionLabel || 'CBS base chart',
|
||||
fromDate:
|
||||
this.dateUtils.formatDate(activeBaseVersion.effectiveFromDate, dateFormat) ||
|
||||
activeBaseVersion.effectiveFromDate,
|
||||
...(activeBaseVersion.effectiveToDate
|
||||
? {
|
||||
endDate:
|
||||
this.dateUtils.formatDate(activeBaseVersion.effectiveToDate, dateFormat) ||
|
||||
activeBaseVersion.effectiveToDate
|
||||
}
|
||||
: {}),
|
||||
isPrimaryGroupingByAmount: false,
|
||||
locale: this.settingsService.language.code,
|
||||
dateFormat,
|
||||
chartSlabs: activeBaseVersion.slabs.map((slab: any) => {
|
||||
const chartSlab: any = {
|
||||
periodType: slab.periodTypeEnum,
|
||||
fromPeriod: slab.fromPeriod,
|
||||
toPeriod: slab.toPeriod,
|
||||
annualInterestRate: slab.annualInterestRate,
|
||||
description: slab.description || '',
|
||||
incentives: [] as any[]
|
||||
};
|
||||
|
||||
if (slab.amountRangeFrom !== null && slab.amountRangeFrom !== undefined && slab.amountRangeFrom !== '') {
|
||||
chartSlab.amountRangeFrom = slab.amountRangeFrom;
|
||||
}
|
||||
|
||||
if (slab.amountRangeTo !== null && slab.amountRangeTo !== undefined && slab.amountRangeTo !== '') {
|
||||
chartSlab.amountRangeTo = slab.amountRangeTo;
|
||||
}
|
||||
|
||||
return chartSlab;
|
||||
})
|
||||
}
|
||||
] as any[];
|
||||
}
|
||||
|
||||
submit() {
|
||||
const DailyDepositSchemeProduct: any = this.pruneEmptyValues({
|
||||
...this.DailyDepositSchemeProduct,
|
||||
charges: (this.DailyDepositSchemeProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
|
||||
locale: this.settingsService.language.code,
|
||||
dateFormat: this.settingsService.dateFormat
|
||||
});
|
||||
if (!DailyDepositSchemeProduct.description) {
|
||||
DailyDepositSchemeProduct.description = '';
|
||||
}
|
||||
delete DailyDepositSchemeProduct.advancedAccountingRules;
|
||||
const productId = this.DailyDepositSchemeProductsTemplate.id;
|
||||
this.productsService
|
||||
.updateRecurringDepositProduct(productId, DailyDepositSchemeProduct)
|
||||
.subscribe((response: any) => {
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
private pruneEmptyValues<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.pruneEmptyValues(item)).filter((item) => item !== undefined) as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, any>).reduce(
|
||||
(acc, [
|
||||
key,
|
||||
currentValue
|
||||
]) => {
|
||||
const cleanedValue = this.pruneEmptyValues(currentValue);
|
||||
if (cleanedValue === undefined) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[key]: cleanedValue
|
||||
};
|
||||
},
|
||||
{} as Record<string, any>
|
||||
) as T;
|
||||
}
|
||||
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="tab-container mat-typography">
|
||||
<mifosx-entity-datatable-tab
|
||||
entityType="Daily Deposit Scheme Product"
|
||||
[entityId]="entityId"
|
||||
[multiRowDatatableFlag]="multiRowDatatableFlag"
|
||||
[entityDatatable]="entityDatatable"
|
||||
>
|
||||
</mifosx-entity-datatable-tab>
|
||||
</div>
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { EntityDatatableTabComponent } from '../../../../shared/tabs/entity-datatable-tab/entity-datatable-tab.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-datatable-tab',
|
||||
templateUrl: './daily-deposit-scheme-datatable-tab.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-datatable-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
EntityDatatableTabComponent
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeDatatableTabComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
entityId: string;
|
||||
entityDatatable: any;
|
||||
multiRowDatatableFlag: boolean;
|
||||
|
||||
constructor() {
|
||||
this.entityId = this.route.parent.parent.snapshot.paramMap.get('productId');
|
||||
|
||||
this.route.data.subscribe((data: { recurringDepositDatatable: any }) => {
|
||||
this.entityDatatable = data.recurringDepositDatatable;
|
||||
this.multiRowDatatableFlag = this.entityDatatable.columnHeaders[0].columnName === 'id' ? true : false;
|
||||
});
|
||||
}
|
||||
}
|
||||
-843
@@ -1,843 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="vsp-root">
|
||||
<!-- HERO -->
|
||||
<div class="vsp-hero">
|
||||
<div class="vsp-hero-left">
|
||||
<div class="vsp-hero-icon"><fa-icon icon="calendar-check"></fa-icon></div>
|
||||
<div class="vsp-hero-text">
|
||||
<div class="vsp-hero-eyebrow">Daily Deposit Scheme Product</div>
|
||||
<h2 class="vsp-hero-title">{{ DailyDepositSchemeProduct.name }}</h2>
|
||||
<div class="vsp-hero-meta">
|
||||
<span class="vsp-pill vsp-pill--code">{{ DailyDepositSchemeProduct.shortName }}</span>
|
||||
@if (DailyDepositSchemeProduct.currency?.code) {
|
||||
<span class="vsp-pill vsp-pill--teal">{{ DailyDepositSchemeProduct.currency.code }}</span>
|
||||
}
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="!DailyDepositSchemeProduct.endDate"
|
||||
[class.vsp-pill--amber]="DailyDepositSchemeProduct.endDate"
|
||||
>
|
||||
<fa-icon [icon]="DailyDepositSchemeProduct.endDate ? 'times-circle' : 'check-circle'"></fa-icon>
|
||||
{{ DailyDepositSchemeProduct.endDate ? 'Inactive' : 'Active' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.description) {
|
||||
<p class="vsp-hero-desc">{{ DailyDepositSchemeProduct.description }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="vsp-edit-btn"
|
||||
type="button"
|
||||
[routerLink]="['../edit']"
|
||||
*mifosxHasPermission="'UPDATE_DailyDepositSchemeProduct'"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
<span>{{ 'labels.buttons.Edit' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DETAILS -->
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
|
||||
<h3>{{ 'labels.inputs.Details' | translate }}</h3>
|
||||
</div>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Short Name' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
><strong>{{ DailyDepositSchemeProduct.shortName }}</strong></span
|
||||
>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.description) {
|
||||
<div class="vsp-kv vsp-kv--span">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Description' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PRODUCT CLASSIFICATION -->
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--purple"><fa-icon icon="layer-group"></fa-icon></div>
|
||||
<h3>Product Classification</h3>
|
||||
</div>
|
||||
<div class="vsp-grid">
|
||||
@if (DailyDepositSchemeProduct.accountType) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Account Type</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.accountType }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.customerSegment) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Customer Segment</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.customerSegment }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.glAccountId) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">GL Account (Liability)</span>
|
||||
<span class="vsp-v">{{
|
||||
DailyDepositSchemeProduct.glAccountName || DailyDepositSchemeProduct.glAccountId
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.effectiveTo) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Effective To</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.effectiveTo | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.endDate) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">End Date</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.endDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Introducer Required</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.introducerRequired"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.introducerRequired"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.introducerRequired | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Deposit Account Link</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.shareLinkingEnabled"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.shareLinkingEnabled"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.shareLinkingEnabled | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CURRENCY -->
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--teal"><fa-icon icon="coins"></fa-icon></div>
|
||||
<h3>{{ 'labels.inputs.Currency' | translate }}</h3>
|
||||
</div>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Currency' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
><strong
|
||||
>({{ DailyDepositSchemeProduct.currency.code }}) {{ DailyDepositSchemeProduct.currency.name }}</strong
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Decimal Places' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.currency.decimalPlaces }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Dormancy Alert</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.dormancyAlert | yesNo }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Balance Swap Option</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.balanceSwapOption | yesNo }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">MSME</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.msme | yesNo }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Nomination Facility</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.nominationFacility | yesNo }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Auto Debit Facility</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.autoDebitFacility | yesNo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TERMS -->
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="percentage"></fa-icon></div>
|
||||
<h3>{{ 'labels.inputs.Terms' | translate }}</h3>
|
||||
</div>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Deposit Amount' | translate }}</span>
|
||||
<span class="vsp-v vsp-v--accent">
|
||||
{{ DailyDepositSchemeProduct.depositAmount | formatNumber }}
|
||||
<span class="vsp-suffix"
|
||||
>(Min: {{ DailyDepositSchemeProduct.minDepositAmount || DailyDepositSchemeProduct.depositAmount }}, Max:
|
||||
{{ DailyDepositSchemeProduct.maxDepositAmount || DailyDepositSchemeProduct.depositAmount }})</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Nominal Annual Interest' | translate }}</span>
|
||||
<span class="vsp-v vsp-v--accent"
|
||||
>{{ DailyDepositSchemeProduct.nominalAnnualInterestRate | formatNumber
|
||||
}}<span class="vsp-suffix">%</span></span
|
||||
>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.effectiveDate) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Effective Date</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.effectiveDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.interestCalculationTypeName) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Interest Calculation Type' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.interestCalculationTypeName }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Interest Calculate</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.interestCalculationType?.value }}</span>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.averagePeriod) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Average Period</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.averagePeriod }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.interestPostingPeriodType.value }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.interestCompoundingPeriodType.value }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Days in Year' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.interestCalculationDaysInYearType.value }}</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Auto Post</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.autoPost"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.autoPost"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.autoPost | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.frequencyFixDay) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Frequency (Fix Day)</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.frequencyFixDay }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.lastInterestDate) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Last Interest Date</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.lastInterestDate | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--indigo"><fa-icon icon="sliders-h"></fa-icon></div>
|
||||
<h3>{{ 'labels.inputs.Settings' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<h4 class="vsp-sub-h">Premature Option</h4>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Loan Facility</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.loanFacility"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.loanFacility"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.loanFacility | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Premature Withdrawal Option</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.prematureWithdrawalOption"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.prematureWithdrawalOption"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.prematureWithdrawalOption | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Tenure Flexibility</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.tenureFlexibility"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.tenureFlexibility"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.tenureFlexibility | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Lien Marked</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.lienMarked"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.lienMarked"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.lienMarked | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.withdrawalFeeForTransfers"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.withdrawalFeeForTransfers"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.withdrawalFeeForTransfers | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="vsp-sub-h">{{ 'labels.inputs.Lock-in Period' | translate }}</h4>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Enable Lock-in Period</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.enableLockinPeriod"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.enableLockinPeriod"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.enableLockinPeriod | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.lockinPeriodFrequency) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Lock-in Period' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
>{{ DailyDepositSchemeProduct.lockinPeriodFrequency }}
|
||||
{{ DailyDepositSchemeProduct.lockinPeriodFrequencyType?.value }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4 class="vsp-sub-h">Maturity Instructions</h4>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Maturity Instructions</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.maturityInstructions"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.maturityInstructions"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.maturityInstructions | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Credit Maturity Proceeds</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.creditMaturityProceeds"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.creditMaturityProceeds"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.creditMaturityProceeds | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Penalty Grace Period (Missed Installments)</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.penaltyGracePeriodMissedInstallments | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Panel Request Applicable</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.panelRequestApplicable"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.panelRequestApplicable"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.panelRequestApplicable | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">No Penalty (Nominee / Heir Closure)</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.noPenaltyNomineeHeirClosure | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Deceased Settlement</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.deceasedSettlement"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.deceasedSettlement"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.deceasedSettlement | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Allow Debit Txn After Due Date</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.allowDebitTxnAfterDueDate"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.allowDebitTxnAfterDueDate"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.allowDebitTxnAfterDueDate | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="vsp-sub-h">Additional Rules</h4>
|
||||
<div class="vsp-grid">
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">Account Closed Zero Balance in Day Closing</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.minBalanceForInterestCalculation) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.minBalanceForInterestCalculation | formatNumber }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.minDepositTerm) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Minimum Deposit Term' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
>{{ DailyDepositSchemeProduct.minDepositTerm }}
|
||||
{{ DailyDepositSchemeProduct.minDepositTermType?.value }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.maxDepositTerm) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Maximum Deposit Term' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
>{{ DailyDepositSchemeProduct.maxDepositTerm }}
|
||||
{{ DailyDepositSchemeProduct.maxDepositTermType?.value }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.inMultipleOfDepositTerm) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Deposit Term In multiple of' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
>{{ DailyDepositSchemeProduct.inMultipleOfDepositTerm }} -
|
||||
{{ DailyDepositSchemeProduct.inMultipleOfDepositTermType?.value }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.isMandatoryDeposit !== undefined) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Daily Deposit Scheme Type' | translate }}</span>
|
||||
<span class="vsp-v">{{
|
||||
DailyDepositSchemeProduct.isMandatoryDeposit
|
||||
? ('labels.text.Mandatory' | translate)
|
||||
: ('labels.text.Voluntary' | translate)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.preClosurePenalInterest) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Apply penal interest (less)' | translate }}</span>
|
||||
<span class="vsp-v"
|
||||
>{{ DailyDepositSchemeProduct.preClosurePenalInterest }}% On
|
||||
{{ DailyDepositSchemeProduct.preClosurePenalInterestOnType?.value }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Withhold Tax is Applicable' | translate }}</span>
|
||||
<span class="vsp-v">
|
||||
<span
|
||||
class="vsp-pill"
|
||||
[class.vsp-pill--green]="DailyDepositSchemeProduct.withHoldTax"
|
||||
[class.vsp-pill--gray]="!DailyDepositSchemeProduct.withHoldTax"
|
||||
>
|
||||
{{ DailyDepositSchemeProduct.withHoldTax | yesNo }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.withHoldTax) {
|
||||
<div class="vsp-kv">
|
||||
<span class="vsp-k">{{ 'labels.inputs.Tax Group' | translate }}</span>
|
||||
<span class="vsp-v">{{ DailyDepositSchemeProduct.taxGroup?.name }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- INTEREST RATE CHART -->
|
||||
@if (DailyDepositSchemeProduct.activeChart) {
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="chart-line"></fa-icon></div>
|
||||
<h3>{{ 'labels.heading.Interest Rate Charts' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Chart meta pills -->
|
||||
<div class="vsp-chart-meta">
|
||||
@if (DailyDepositSchemeProduct.activeChart.name) {
|
||||
<span class="vsp-chart-pill"
|
||||
><fa-icon icon="tag"></fa-icon> {{ DailyDepositSchemeProduct.activeChart.name }}</span
|
||||
>
|
||||
}
|
||||
<span class="vsp-chart-pill"
|
||||
><fa-icon icon="calendar"></fa-icon> {{ DailyDepositSchemeProduct.activeChart.fromDate | dateFormat }}</span
|
||||
>
|
||||
@if (DailyDepositSchemeProduct.activeChart.endDate) {
|
||||
<span class="vsp-chart-pill vsp-chart-pill--red"
|
||||
><fa-icon icon="calendar-times"></fa-icon>
|
||||
{{ DailyDepositSchemeProduct.activeChart.endDate | dateFormat }}</span
|
||||
>
|
||||
}
|
||||
<span
|
||||
class="vsp-chart-pill"
|
||||
[class.vsp-chart-pill--green]="DailyDepositSchemeProduct.activeChart.isPrimaryGroupingByAmount"
|
||||
>
|
||||
Primary by Amount: {{ DailyDepositSchemeProduct.activeChart.isPrimaryGroupingByAmount | yesNo }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="vsp-table-wrap">
|
||||
<table
|
||||
class="vsp-table"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.activeChart.chartSlabs"
|
||||
multiTemplateDataRows
|
||||
>
|
||||
<ng-container matColumnDef="srNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Sr.</th>
|
||||
<td mat-cell *matCellDef="let chartSlab; let i = dataIndex">{{ i + 1 }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Particular</th>
|
||||
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.description }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fromPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.fromPeriod }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="toPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.toPeriod ?? '—' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="periodTypeLabel">
|
||||
<th mat-header-cell *matHeaderCellDef>Period</th>
|
||||
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.periodType?.value }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="annualInterestRate">
|
||||
<th mat-header-cell *matHeaderCellDef>Rate (%)</th>
|
||||
<td mat-cell *matCellDef="let chartSlab">
|
||||
<strong>{{ chartSlab.annualInterestRate }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let chartSlab; let chartSlabIndex = dataIndex">
|
||||
<button
|
||||
class="vsp-incentive-btn"
|
||||
type="button"
|
||||
(click)="expandChartSlabIndex = expandChartSlabIndex === chartSlabIndex ? null : chartSlabIndex"
|
||||
>
|
||||
<fa-icon [icon]="expandChartSlabIndex === chartSlabIndex ? 'eye-slash' : 'eye'"></fa-icon>
|
||||
{{
|
||||
expandChartSlabIndex === chartSlabIndex
|
||||
? ('labels.buttons.Hide Incentives' | translate)
|
||||
: ('labels.buttons.View Incentives' | translate)
|
||||
}}
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incentives">
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let chartSlab; let chartSlabIndex = dataIndex"
|
||||
[attr.colspan]="chartSlabsDisplayedColumns.length"
|
||||
class="vsp-incentive-cell"
|
||||
>
|
||||
<div
|
||||
[@expandChartSlab]="chartSlabIndex === expandChartSlabIndex ? 'expanded' : 'collapsed'"
|
||||
class="vsp-incentive-panel"
|
||||
>
|
||||
<p class="vsp-incentive-title">{{ 'labels.heading.Incentives' | translate }}</p>
|
||||
<div class="vsp-table-wrap">
|
||||
<table class="vsp-table" mat-table [dataSource]="chartSlab.incentives">
|
||||
<ng-container matColumnDef="entityType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Entity Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let incentive">
|
||||
{{
|
||||
incentive.entityType
|
||||
| find: DailyDepositSchemeProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="attributeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Attribute Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let incentive">
|
||||
{{
|
||||
incentive.attributeName
|
||||
| find
|
||||
: DailyDepositSchemeProductTemplate.chartTemplate.attributeNameOptions
|
||||
: 'id'
|
||||
: 'value'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="conditionType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Condition Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let incentive">
|
||||
{{
|
||||
incentive.conditionType
|
||||
| find
|
||||
: DailyDepositSchemeProductTemplate.chartTemplate.conditionTypeOptions
|
||||
: 'id'
|
||||
: 'value'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="attributeValue">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Attribute Value' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let incentive">
|
||||
@switch (incentive.attributeName) {
|
||||
@case (2) {
|
||||
<span>{{
|
||||
incentive.attributeValue
|
||||
| find: DailyDepositSchemeProductTemplate.chartTemplate.genderOptions : 'id' : 'name'
|
||||
}}</span>
|
||||
}
|
||||
@case (3) {
|
||||
<span>{{ incentive.attributeValue }}</span>
|
||||
}
|
||||
@case (4) {
|
||||
<span>{{
|
||||
incentive.attributeValue
|
||||
| find
|
||||
: DailyDepositSchemeProductTemplate.chartTemplate.clientTypeOptions
|
||||
: 'id'
|
||||
: 'name'
|
||||
}}</span>
|
||||
}
|
||||
@case (5) {
|
||||
<span>{{
|
||||
incentive.attributeValue
|
||||
| find
|
||||
: DailyDepositSchemeProductTemplate.chartTemplate.clientClassificationOptions
|
||||
: 'id'
|
||||
: 'name'
|
||||
}}</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incentiveType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Incentive Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let incentive">
|
||||
{{
|
||||
incentive.incentiveType
|
||||
| find
|
||||
: DailyDepositSchemeProductTemplate.chartTemplate.incentiveTypeOptions
|
||||
: 'id'
|
||||
: 'value'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let incentive">{{ incentive.amount }}</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="incentivesDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: incentivesDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="chartSlabsDisplayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: chartSlabsDisplayedColumns; let i = dataIndex"
|
||||
[class.vsp-row-even]="i % 2 === 1"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: chartSlabsIncentivesDisplayedColumns" class="incentives-row"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- CHARGES -->
|
||||
@if (DailyDepositSchemeProduct.charges.length) {
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--red"><fa-icon icon="receipt"></fa-icon></div>
|
||||
<h3>{{ 'labels.heading.Charges' | translate }}</h3>
|
||||
</div>
|
||||
<div class="vsp-table-wrap">
|
||||
<table class="vsp-table" mat-table [dataSource]="DailyDepositSchemeProduct.charges">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.name }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="type">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.chargeCalculationType.value }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.amount | formatNumber }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="collectedon">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Collected On' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">{{ charge.chargeTimeType.value }}</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="chargesDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: chargesDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- ACCOUNTING -->
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="book"></fa-icon></div>
|
||||
<h3>{{ 'labels.heading.Accounting' | translate }}</h3>
|
||||
</div>
|
||||
<mifosx-view-savings-accounting-details
|
||||
[accountingRule]="DailyDepositSchemeProduct.accountingRule"
|
||||
[accountingMappings]="DailyDepositSchemeProduct.accountingMappings"
|
||||
></mifosx-view-savings-accounting-details>
|
||||
</section>
|
||||
|
||||
<!-- ADVANCED ACCOUNTING RULES -->
|
||||
@if (
|
||||
DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length ||
|
||||
DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length ||
|
||||
DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length
|
||||
) {
|
||||
<section class="vsp-section">
|
||||
<div class="vsp-section-h">
|
||||
<div class="vsp-section-ic vsp-section-ic--purple"><fa-icon icon="sitemap"></fa-icon></div>
|
||||
<h3>{{ 'labels.inputs.Advanced Accounting Rules' | translate }}</h3>
|
||||
</div>
|
||||
@if (DailyDepositSchemeProduct.paymentChannelToFundSourceMappings?.length) {
|
||||
<h4 class="vsp-sub-h">{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}</h4>
|
||||
<div class="vsp-table-wrap">
|
||||
<table
|
||||
class="vsp-table"
|
||||
mat-table
|
||||
[dataSource]="DailyDepositSchemeProduct.paymentChannelToFundSourceMappings"
|
||||
>
|
||||
<ng-container matColumnDef="paymentTypeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.paymentType.name }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fundSourceAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.fundSourceAccount.name }}</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.feeToIncomeAccountMappings?.length) {
|
||||
<h4 class="vsp-sub-h">{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}</h4>
|
||||
<div class="vsp-table-wrap">
|
||||
<table class="vsp-table" mat-table [dataSource]="DailyDepositSchemeProduct.feeToIncomeAccountMappings">
|
||||
<ng-container matColumnDef="chargeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fee/Charge' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let f">{{ f.charge.name }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incomeAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let f">{{ f.incomeAccount.name }}</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (DailyDepositSchemeProduct.penaltyToIncomeAccountMappings?.length) {
|
||||
<h4 class="vsp-sub-h">{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}</h4>
|
||||
<div class="vsp-table-wrap">
|
||||
<table class="vsp-table" mat-table [dataSource]="DailyDepositSchemeProduct.penaltyToIncomeAccountMappings">
|
||||
<ng-container matColumnDef="chargeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.charge.name }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incomeAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.incomeAccount.name }}</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
-528
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--vsp-navy: #0b1e3d;
|
||||
--vsp-blue: #1d4ed8;
|
||||
--vsp-blue-l: #eff6ff;
|
||||
--vsp-teal: #0e7e6b;
|
||||
--vsp-teal-l: #ccfbf1;
|
||||
--vsp-purple: #6d28d9;
|
||||
--vsp-purple-l: #ede9fe;
|
||||
--vsp-indigo: #3730a3;
|
||||
--vsp-indigo-l: #e0e7ff;
|
||||
--vsp-amber: #b45309;
|
||||
--vsp-amber-l: #fef3c7;
|
||||
--vsp-green: #15803d;
|
||||
--vsp-green-l: #dcfce7;
|
||||
--vsp-red: #c0392b;
|
||||
--vsp-red-l: #fee2e2;
|
||||
--vsp-pink: #be185d;
|
||||
--vsp-pink-l: #fce7f3;
|
||||
--vsp-g50: #f8fafc;
|
||||
--vsp-g100: #f1f5f9;
|
||||
--vsp-g200: #e2e8f0;
|
||||
--vsp-g500: #64748b;
|
||||
--vsp-g700: #334155;
|
||||
--vsp-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family:
|
||||
'DM Sans',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--vsp-g900);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.vsp-root {
|
||||
padding: 4px 4px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vsp-hero {
|
||||
background: linear-gradient(135deg, var(--vsp-navy) 0%, var(--vsp-blue) 100%);
|
||||
border-radius: 14px;
|
||||
padding: 22px 22px 22px 18px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
box-shadow:
|
||||
0 10px 24px rgb(11 30 61 / 18%),
|
||||
0 4px 10px rgb(11 30 61 / 12%);
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vsp-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
right: -60px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vsp-hero-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vsp-hero-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
border: 1px solid rgb(255 255 255 / 22%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vsp-hero-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vsp-hero-eyebrow {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vsp-hero-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vsp-hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vsp-hero-desc {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
line-height: 1.5;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.vsp-edit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 14%);
|
||||
color: #fff;
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 24%);
|
||||
border-color: rgb(255 255 255 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-family: inherit;
|
||||
|
||||
fa-icon {
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
&--code {
|
||||
background: rgb(255 255 255 / 18%);
|
||||
color: #fff;
|
||||
border: 1px solid rgb(255 255 255 / 24%);
|
||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: var(--vsp-teal-l);
|
||||
color: var(--vsp-teal);
|
||||
border: 1px solid #99f6e4;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: var(--vsp-blue-l);
|
||||
color: var(--vsp-blue);
|
||||
border: 1px solid #dbeafe;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: var(--vsp-green-l);
|
||||
color: var(--vsp-green);
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: var(--vsp-amber-l);
|
||||
color: var(--vsp-amber);
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--vsp-red-l);
|
||||
color: var(--vsp-red);
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: var(--vsp-g100);
|
||||
color: var(--vsp-g700);
|
||||
border: 1px solid var(--vsp-g200);
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-section {
|
||||
background: #fff;
|
||||
border: 1px solid var(--vsp-g200);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
}
|
||||
|
||||
.vsp-section-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed var(--vsp-g200);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--vsp-g900);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-section-ic {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--blue {
|
||||
background: var(--vsp-blue-l);
|
||||
color: var(--vsp-blue);
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: var(--vsp-teal-l);
|
||||
color: var(--vsp-teal);
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: var(--vsp-purple-l);
|
||||
color: var(--vsp-purple);
|
||||
}
|
||||
|
||||
&--indigo {
|
||||
background: var(--vsp-indigo-l);
|
||||
color: var(--vsp-indigo);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: var(--vsp-amber-l);
|
||||
color: var(--vsp-amber);
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: var(--vsp-green-l);
|
||||
color: var(--vsp-green);
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--vsp-red-l);
|
||||
color: var(--vsp-red);
|
||||
}
|
||||
|
||||
&--pink {
|
||||
background: var(--vsp-pink-l);
|
||||
color: var(--vsp-pink);
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-sub-h {
|
||||
margin: 16px 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--vsp-g700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.vsp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 24px;
|
||||
}
|
||||
|
||||
@media (width <=600px) {
|
||||
.vsp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--vsp-g100);
|
||||
|
||||
&--span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-k {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--vsp-g500);
|
||||
}
|
||||
|
||||
.vsp-v {
|
||||
font-size: 13.5px;
|
||||
color: var(--vsp-g900);
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-v--accent {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--vsp-blue);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.vsp-suffix {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vsp-g500);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.vsp-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--vsp-g200);
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ───────── CHART META PILLS ───────── */
|
||||
.vsp-chart-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.vsp-chart-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--vsp-g100);
|
||||
border: 1px solid var(--vsp-g200);
|
||||
color: var(--vsp-g700);
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
color: var(--vsp-g500);
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: var(--vsp-green-l);
|
||||
border-color: #bbf7d0;
|
||||
color: var(--vsp-green);
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--vsp-red-l);
|
||||
border-color: #fecaca;
|
||||
color: var(--vsp-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── RATE BADGE ───────── */
|
||||
.vsp-rate-badge {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--vsp-blue);
|
||||
background: var(--vsp-blue-l);
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 6px;
|
||||
padding: 3px 10px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ───────── CELL SUB TEXT ───────── */
|
||||
.vsp-cell-sub {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
color: var(--vsp-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ───────── INCENTIVE EXPAND ───────── */
|
||||
.vsp-incentive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vsp-g200);
|
||||
background: var(--vsp-g50);
|
||||
color: var(--vsp-blue);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.14s ease;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--vsp-blue-l);
|
||||
border-color: #dbeafe;
|
||||
}
|
||||
}
|
||||
|
||||
.vsp-incentive-cell {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.vsp-incentive-panel {
|
||||
overflow: hidden;
|
||||
padding: 0 14px;
|
||||
background: var(--vsp-g50);
|
||||
border-top: 1px dashed var(--vsp-g200);
|
||||
}
|
||||
|
||||
.vsp-incentive-title {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--vsp-g500);
|
||||
}
|
||||
|
||||
.vsp-table {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
|
||||
th.mat-mdc-header-cell {
|
||||
background: var(--vsp-g50);
|
||||
color: var(--vsp-g700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid var(--vsp-g200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td.mat-mdc-cell {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--vsp-g100);
|
||||
font-size: 12.5px;
|
||||
color: var(--vsp-g900);
|
||||
}
|
||||
|
||||
tbody tr:last-child td.mat-mdc-cell {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
tbody tr:not(.incentives-row, .vsp-row-even) td.mat-mdc-cell {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
tbody tr.vsp-row-even td.mat-mdc-cell {
|
||||
background: var(--vsp-g50) !important;
|
||||
}
|
||||
|
||||
tr.incentives-row {
|
||||
height: 0;
|
||||
|
||||
> td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.incentives {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { trigger, state, transition, animate, style } from '@angular/animations';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component';
|
||||
import { FindPipe } from '../../../../pipes/find.pipe';
|
||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-daily-deposit-scheme-general-tab',
|
||||
templateUrl: './daily-deposit-scheme-general-tab.component.html',
|
||||
styleUrls: ['./daily-deposit-scheme-general-tab.component.scss'],
|
||||
animations: [
|
||||
trigger('expandChartSlab', [
|
||||
state('collapsed', style({ height: '0px', minHeight: '0' })),
|
||||
state('expanded', style({ height: '*' })),
|
||||
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
|
||||
])
|
||||
],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
FaIconComponent,
|
||||
MatDivider,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
ViewSavingsAccountingDetailsComponent,
|
||||
FindPipe,
|
||||
DateFormatPipe,
|
||||
FormatNumberPipe,
|
||||
YesnoPipe
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeGeneralTabComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
DailyDepositSchemeProduct: any;
|
||||
DailyDepositSchemeProductTemplate: any;
|
||||
expandChartSlabIndex: number | null = null;
|
||||
|
||||
chartSlabsIncentivesDisplayedColumns: string[] = ['incentives'];
|
||||
readonly chartSlabsBaseColumns = [
|
||||
'srNo',
|
||||
'description',
|
||||
'fromPeriod',
|
||||
'toPeriod',
|
||||
'periodTypeLabel',
|
||||
'annualInterestRate'
|
||||
];
|
||||
|
||||
get chartSlabsDisplayedColumns(): string[] {
|
||||
const slabs = this.DailyDepositSchemeProduct?.activeChart?.chartSlabs || [];
|
||||
const hasIncentives = slabs.some((s: any) => s.incentives?.length);
|
||||
return hasIncentives ? [
|
||||
...this.chartSlabsBaseColumns,
|
||||
'actions'
|
||||
] : this.chartSlabsBaseColumns;
|
||||
}
|
||||
incentivesDisplayedColumns: string[] = [
|
||||
'entityType',
|
||||
'attributeName',
|
||||
'conditionType',
|
||||
'attributeValue',
|
||||
'incentiveType',
|
||||
'amount'
|
||||
];
|
||||
chargesDisplayedColumns: string[] = [
|
||||
'name',
|
||||
'type',
|
||||
'amount',
|
||||
'collectedon'
|
||||
];
|
||||
paymentFundSourceDisplayedColumns: string[] = [
|
||||
'paymentTypeId',
|
||||
'fundSourceAccountId'
|
||||
];
|
||||
feesPenaltyIncomeDisplayedColumns: string[] = [
|
||||
'chargeId',
|
||||
'incomeAccountId'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { DailyDepositSchemeProduct: any; DailyDepositSchemeProductsTemplate: any }) => {
|
||||
this.DailyDepositSchemeProduct = data.DailyDepositSchemeProduct;
|
||||
this.DailyDepositSchemeProductTemplate = data.DailyDepositSchemeProductsTemplate;
|
||||
});
|
||||
}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="tab-container mat-typography">
|
||||
@if (!rdInterestRates || rdInterestRates.length === 0) {
|
||||
<p class="no-rates">No CBS base interest rate versions defined.</p>
|
||||
}
|
||||
|
||||
<mat-accordion multi>
|
||||
@for (version of rdInterestRates; track version.id) {
|
||||
<mat-expansion-panel [expanded]="version.isActive">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<span class="rd-version-header">
|
||||
<strong>{{ version.versionLabel }}</strong>
|
||||
<span [class]="version.isActive ? 'rd-active-badge' : 'rd-inactive-badge'">
|
||||
{{ version.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span style="color: #757575; font-size: 13px">Effective from: {{ version.effectiveFromDate }}</span>
|
||||
</span>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
@if (version.remarks) {
|
||||
<p>
|
||||
<em>{{ version.remarks }}</em>
|
||||
</p>
|
||||
}
|
||||
|
||||
<table class="rd-slab-table mat-elevation-z1" mat-table [dataSource]="version.slabs">
|
||||
<ng-container matColumnDef="srNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Sr.</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Particular</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.description }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fromPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.fromPeriod }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="toPeriod">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? '—' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="periodTypeLabel">
|
||||
<th mat-header-cell *matHeaderCellDef>Period Type</th>
|
||||
<td mat-cell *matCellDef="let slab">{{ slab.periodTypeLabel }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="annualInterestRate">
|
||||
<th mat-header-cell *matHeaderCellDef>Annual Rate (%)</th>
|
||||
<td mat-cell *matCellDef="let slab">
|
||||
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="slabColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: slabColumns"></tr>
|
||||
</table>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
</div>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.rd-version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rd-active-badge {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rd-inactive-badge {
|
||||
background: #9e9e9e;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rd-slab-table {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-rates {
|
||||
padding: 24px;
|
||||
color: #757575;
|
||||
text-align: center;
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-rd-interest-rates-tab',
|
||||
templateUrl: './rd-interest-rates-tab.component.html',
|
||||
styleUrls: ['./rd-interest-rates-tab.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatExpansionModule,
|
||||
DecimalPipe
|
||||
]
|
||||
})
|
||||
export class DailyDepositSchemeInterestRatesTabComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
rdInterestRates: any[] = [];
|
||||
|
||||
readonly slabColumns = [
|
||||
'srNo',
|
||||
'description',
|
||||
'fromPeriod',
|
||||
'toPeriod',
|
||||
'periodTypeLabel',
|
||||
'annualInterestRate'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { rdInterestRates: any[] }) => {
|
||||
this.rdInterestRates = data.rdInterestRates || [];
|
||||
});
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { inject } from '@angular/core';
|
||||
import { ResolveFn } from '@angular/router';
|
||||
import { ProductsService } from '../../../products.service';
|
||||
|
||||
export const rdInterestRatesResolver: ResolveFn<any[]> = (route) => {
|
||||
const productsService = inject(ProductsService);
|
||||
return productsService.getRdBaseInterestRates();
|
||||
};
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<mat-card class="product-card">
|
||||
<mat-card-content>
|
||||
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./general']"
|
||||
routerLinkActive
|
||||
#general="routerLinkActive"
|
||||
[active]="general.isActive"
|
||||
>
|
||||
{{ 'labels.heading.General' | translate }}
|
||||
</a>
|
||||
@for (recurringDepositDatatable of recurringDepositDatatables; track recurringDepositDatatable) {
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./datatables', recurringDepositDatatable.registeredTableName]"
|
||||
routerLinkActive
|
||||
#datatable="routerLinkActive"
|
||||
[active]="datatable.isActive"
|
||||
*mifosxHasPermission="'READ_' + recurringDepositDatatable.registeredTableName"
|
||||
>
|
||||
{{ recurringDepositDatatable.registeredTableName }}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<mat-tab-nav-panel #tabPanel>
|
||||
<router-outlet></router-outlet>
|
||||
</mat-tab-nav-panel>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.product-card {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
width: 90%;
|
||||
padding: 0;
|
||||
|
||||
.navigation-tabs {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
i:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user