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);
}