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:
tarunmalviya
2026-09-23 11:54:49 +05:30
co-authored by Claude Opus 5
parent 57a61b3057
commit e2eff9914f
114 changed files with 10259 additions and 15955 deletions
@@ -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}`;
}
}
@@ -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>
@@ -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%);
}
}
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 } : {});
}
}
@@ -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>
@@ -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;
}
@@ -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 } : {})
});
}
}