DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes

This commit is contained in:
tarunmalviya
2026-10-01 13:08:52 +05:30
parent d47e0860f6
commit 885b39d970
48 changed files with 5878 additions and 374 deletions
@@ -0,0 +1,381 @@
<!--
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="dialog-wrapper">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="header-text">
<h2>Apply DDS Receipt</h2>
<p>
Agent <strong class="mono">{{ data.agentCode }}</strong> · {{ data.agentName }}
@if (data.glCode) {
· GL <strong class="mono">{{ data.glCode }}</strong>
}
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dialog-body">
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading entries and receipts…</span>
</div>
} @else {
<!-- What the picked vouchers have to cover, and whether they do. -->
<div class="sc-card">
<div class="sc-row" [class.sc-ok]="canApply" [class.sc-bad]="pickedReceipts.size > 0 && difference !== 0">
<div class="sc-left">
<span class="sc-label">Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</span>
<span class="sc-sub"
>{{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle</span
>
</div>
<div class="sc-right">
<span class="sc-val">{{ required | number: '1.2-2' }}</span>
</div>
</div>
<div
class="sc-row sc-sep"
[class.sc-ok]="canApply"
[class.sc-bad]="pickedReceipts.size > 0 && difference !== 0"
>
<div class="sc-left">
<span class="sc-label">Vouchers picked</span>
<span class="sc-sub"
>{{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }}</span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="canApply"
[class.sc-val-bad]="pickedReceipts.size > 0 && difference !== 0"
>
{{ received | number: '1.2-2' }}
</span>
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) {
<span class="sc-tag sc-tag-bad">Short {{ -difference | number: '1.2-2' }}</span>
} @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) {
<span class="sc-tag sc-tag-bad">Over {{ difference | number: '1.2-2' }}</span>
} @else if (canApply) {
<span class="sc-tag sc-tag-ok">✓ Matches</span>
}
</div>
</div>
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total to Apply</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">{{ received | number: '1.2-2' }}</span>
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
</div>
</div>
</div>
<!-- ── 1: the days the agent wrote down ──────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</h3>
<p>Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.</p>
</div>
</header>
@if (days.length) {
<ul class="options">
@for (day of days; track day.date) {
<li class="option" (click)="toggleOpen(day, $event)">
<div class="option-main">
<span class="option-voucher mono">{{ day.date | date: 'dd/MM/yyyy' }}</span>
<span class="option-meta"
>{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}</span
>
</div>
<span class="option-amount mono">{{ day.total | number: '1.2-2' }}</span>
<button
type="button"
class="option-info"
(click)="toggleOpen(day, $event)"
aria-label="Show the day's lines"
>
<mat-icon>{{ openDay === day.date ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</li>
@if (openDay === day.date) {
<li class="day-lines">
@if (day.entries.length > 8) {
<input
class="day-search"
type="text"
[(ngModel)]="daySearch"
placeholder="Search party code or name"
autocomplete="off"
/>
}
<div class="day-scroll">
<table class="entries">
<tbody>
@for (entry of visibleEntries(day); track entry.id) {
<tr>
<td class="entry-code">{{ entry.partyCode }}</td>
<td class="entry-name">{{ entry.partyName }}</td>
<td>
{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</td>
<td class="num mono">{{ entry.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="entry-name">No line matches.</td>
</tr>
}
</tbody>
</table>
</div>
</li>
}
}
</ul>
} @else {
<p class="slot-empty">
{{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date
on the screen to the day the entries were written.
</p>
}
</section>
<!-- ── 2: the cashier's receipts ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>DDS Cash Receipts</h3>
<p>Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch()">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (available.length) {
<ul class="options">
@for (option of available; track option.id) {
<li class="option" [class.option-picked]="pickedReceipts.has(option.id)" (click)="toggleReceipt(option)">
<mat-icon class="option-check">{{
pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank'
}}</mat-icon>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.txnDate }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
<button type="button" class="option-info" (click)="openDetail(option, $event)" aria-label="GL entries">
<mat-icon>receipt_long</mat-icon>
</button>
</li>
}
</ul>
} @else {
<p class="slot-empty">
No DDS receipt is waiting on this agent's GL account. Collect the cash at
<em>Cashier › Transaction Entry</em> with entry type <strong>DDS</strong> and have the manager approve it,
or find an earlier voucher by number.
</p>
}
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) {
<p class="slot-note note-bad">
The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{
required | number: '1.2-2'
}}) exactly, so every customer is credited what the agent wrote down.
</p>
}
</section>
@if (errorMsg && !searchOpen) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Receipt</span>
}
</button>
</mat-dialog-actions>
</div>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchOpen) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>Find a DDS receipt</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<div class="field-readonly mono">{{ data.glCode || '—' }} · {{ data.agentCode }}</div>
</label>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/24092026/0001"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.txnDate }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.managerName) {
<div>
<dt>Approved By</dt>
<dd>{{ viewing.managerName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track entry.id) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.glName }}
</td>
<td class="num mono">{{ entry.drCr === 'DR' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'CR' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -0,0 +1,95 @@
/**
* 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 same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was
// collected for, and the counter should meet one screen, not two. Used as it is, never changed here.
@use '../../../shares/share-dialog.shared' as *;
:host {
display: block;
width: 100%;
}
// The agent's outstanding lines - read only, what the receipt has to cover.
.entries {
width: 100%;
border-collapse: collapse;
font-size: 13px;
td {
padding: 7px 4px;
border-bottom: 1px solid #eef2f7;
}
tr:last-child td {
border-bottom: none;
}
.entry-date {
color: #64748b;
white-space: nowrap;
}
.entry-code {
font-weight: 600;
white-space: nowrap;
}
.entry-name {
color: #475569;
}
.num {
text-align: right;
white-space: nowrap;
}
}
// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio.
.option-check {
color: #94a3b8;
flex-shrink: 0;
}
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
.option-check {
color: #2563eb;
}
}
// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the
// dialog a thousand lines tall.
.day-lines {
list-style: none;
padding: 6px 10px 10px 42px;
}
.day-search {
width: 100%;
margin-bottom: 6px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font: inherit;
font-size: 13px;
}
.day-scroll {
max-height: 240px;
overflow-y: auto;
}
.field-readonly {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
@@ -0,0 +1,351 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
/** One day of an agent's outstanding entries, as the dialog lists them. */
export interface EntryDay {
date: string;
entries: any[];
total: number;
}
/**
* Applies the cashier's DDS receipts to an agent's outstanding collections.
*
* The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL
* account, tagged DDS, and the manager approved it. Applying it here hands the money on to the
* customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is
* settled against the application. The same screen as Apply Additional Shares.
*
* Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick,
* because what the agent wrote down is settled whole, and an agent who comes in after several days
* brings several days' cash. The vouchers are ticked, since a day's
* cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what
* each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave
* money nobody can place.
*
* The day is shown totalled and its lines opened only when asked for - an agent with a thousand
* customers is a thousand lines, and the day's total is what the counter is checking.
*/
@Component({
selector: 'mifosx-apply-dds-receipt-dialog',
templateUrl: './apply-dds-receipt-dialog.component.html',
styleUrls: ['./apply-dds-receipt-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplyDdsReceiptDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
public dialogRef = inject(MatDialogRef<ApplyDdsReceiptDialogComponent>);
public data = inject<{
agentId: number;
agentCode: string;
agentName: string;
glCode: string | null;
entryDate: string;
}>(MAT_DIALOG_DATA);
loadingEntries = true;
loadingReceipts = true;
submitting = false;
errorMsg = '';
/** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */
days: EntryDay[] = [];
/** DDS receipts on the agent's GL account, approved and not yet applied. */
available: any[] = [];
/**
* The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none
* is picked for them even when only one is listed.
*/
pickedDays = new Set<string>();
pickedReceipts = new Set<number>();
/** The day whose lines are open, and the search within it. */
openDay: string | null = null;
daySearch = '';
get loading(): boolean {
return this.loadingEntries || this.loadingReceipts;
}
ngOnInit(): void {
// Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for
// several days arrives in one visit, and all of it is settled together.
this.organizationService
.getAgentPendingCollections(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, "Could not load the agent's entries.");
return of(null);
})
)
.subscribe((res: any) => {
this.days = groupByDay(
(res?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate
)
);
this.pickedDays = new Set(this.days.map((day) => day.date));
this.loadingEntries = false;
});
this.organizationService
.getAgentCashReceipts(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
return of([]);
})
)
.subscribe((rows: any) => {
this.available = Array.isArray(rows) ? rows : [];
this.loadingReceipts = false;
});
}
// ── Picking ─────────────────────────────────────────────────────────────────────────────────
toggleReceipt(receipt: any): void {
if (this.submitting) {
return;
}
this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id);
}
toggleOpen(day: EntryDay, event: Event): void {
event.stopPropagation();
this.openDay = this.openDay === day.date ? null : day.date;
this.daySearch = '';
}
/** The open day's lines, narrowed by party code or name. */
visibleEntries(day: EntryDay): any[] {
const term = this.daySearch.trim().toLowerCase();
if (!term) {
return day.entries;
}
return day.entries.filter(
(entry: any) =>
String(entry.partyCode || '')
.toLowerCase()
.includes(term) ||
String(entry.partyName || '')
.toLowerCase()
.includes(term)
);
}
// ── What has to be covered ──────────────────────────────────────────────────────────────────
get entryCount(): number {
return this.days.reduce((sum, day) => sum + day.entries.length, 0);
}
get outstandingTotal(): number {
return this.days.reduce((sum, day) => sum + day.total, 0);
}
get pickedEntryCount(): number {
return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0);
}
get required(): number {
return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0));
}
get received(): number {
return round2(
this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0)
);
}
/** Vouchers less entries: below zero they are short, above zero they are over. */
get difference(): number {
return round2(this.received - this.required);
}
get canApply(): boolean {
return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting;
}
glLabel(option: any): string {
return [
option?.glCode,
option?.glName
]
.filter(Boolean)
.join(' — ');
}
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searching = false;
searchOpen = false;
searchVoucherNo = '';
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
/**
* Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as
* DDS, not approved yet, or already applied - rather than simply not finding it.
*/
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
if (!voucher || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.organizationService
.getAgentCashReceipts(this.data.agentId, voucher)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`);
return of(null);
})
)
.subscribe((rows: any) => {
this.searching = false;
if (rows === null) {
return;
}
const found = (Array.isArray(rows) ? rows : [])[0];
if (!found) {
this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`;
} else if (found.entryType !== 'DDS') {
this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`;
} else if (found.status !== 'APPROVED') {
this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`;
} else if (found.applied) {
this.errorMsg = `Voucher ${voucher} has already been applied.`;
} else {
this.available = [
found,
...this.available.filter((entry) => entry.id !== found.id)
];
this.pickedReceipts.add(found.id);
this.searchOpen = false;
}
});
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: any = null;
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: any, event: Event): void {
event.stopPropagation();
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.organizationService
.getCashReceiptGlEntries(option.id)
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.viewingGlEntries = Array.isArray(rows) ? rows : [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (!this.canApply) {
return;
}
this.submitting = true;
this.errorMsg = '';
const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo);
const entries = this.pickedEntryCount;
const amount = this.required;
this.organizationService
.settleAgentCollections({
agentId: Number(this.data.agentId),
cashierDepositRequestIds: Array.from(this.pickedReceipts),
entryDates: Array.from(this.pickedDays),
locale: this.settingsService.language.code
})
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The receipts could not be applied.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close({ vouchers, entries, amount, response: res });
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.developerMessage ??
fallback
);
}
}
/** Entries grouped by the day they were collected, oldest first, each day totalled. */
export function groupByDay(entries: any[]): EntryDay[] {
const byDay = new Map<string, EntryDay>();
for (const entry of entries) {
const date = String(entry.entryDate);
const day = byDay.get(date) ?? { date, entries: [], total: 0 };
day.entries.push(entry);
day.total = round2(day.total + Number(entry.amount || 0));
byDay.set(date, day);
}
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -79,8 +79,8 @@
</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.
Pick a party in a collection row below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
@@ -121,7 +121,7 @@
required
[matDatepicker]="txnDatePicker"
formControlName="transactionDate"
(dateChange)="loadPendingEntries()"
(dateChange)="loadPendingEntries(); loadAgentMoney()"
/>
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
@@ -132,7 +132,10 @@
<mat-select required formControlName="transactionType">
@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>}
{{ type.label }}
@if (!type.supported) {
<span class="dte-soon"> — not available yet</span>
}
</mat-option>
}
</mat-select>
@@ -151,7 +154,7 @@
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections to settle</h3>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
@@ -160,15 +163,25 @@
@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>
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<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__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
@@ -181,49 +194,145 @@
<!--
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.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected">
Take <strong>{{ pendingTotal | number: '1.2-2' }}</strong> from {{ agentInfo.name }}, then post.
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
@if (isAgentTran) {
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent Settlement</h3>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
<p class="dte-info-panel__hint">
Cash the agent hands over to the branch, deducted from the agent's collected balance shown above.
</p>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="settlementAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="settlementMode">
@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="settlementAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput formControlName="settlementAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="settlementDescription" />
</mat-form-field>
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
Master.
</p>
} @else {
<!-- The GL the cashier takes this agent's cash into - the code to key at Transaction Entry. -->
<p class="dte-expected">
Agent GL: <strong>{{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }}</strong> — the cashier
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the
right agent.
</p>
}
@if (agentWaiting > 0) {
<p class="dte-expected dte-expected--note">
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; 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 }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@@ -236,28 +345,80 @@
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input matInput formControlName="creditorPartyCode" />
<input
matInput
formControlName="creditorPartyCode"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" />
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" />
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input matInput formControlName="debitPartyCode" />
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" />
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" />
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
@@ -287,17 +448,20 @@
<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>
<!-- 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>
<!-- Party Tran and Agent Tran are read here, never saved: the agent writes the round down on
their own screen and the cash is taken in at the cashier's counter. Agent Tran applies the
cashier's receipts from its own Apply DDS Receipt. Only Withdraw Deposite still saves. -->
@if (!isPartyTran && !isAgentTran) {
<button
mat-raised-button
color="primary"
type="button"
[disabled]="entryForm.invalid || transferToItself"
(click)="save()"
>
Save
</button>
}
</div>
</form>
</div>
@@ -160,13 +160,14 @@ $primary: #0f766e;
.dte-collection-row {
display: grid;
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 */
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;
@@ -275,7 +276,6 @@ $primary: #0f766e;
color: rgb(0 0 0 / 45%);
}
/*
* Marks a party this agent has already collected from today, on the row where it is picked.
*
@@ -334,7 +334,6 @@ $primary: #0f766e;
font-weight: 500;
}
/*
The agent's written-down round, as the counter reads it.
@@ -347,12 +346,19 @@ $primary: #0f766e;
align-items: baseline;
gap: 12px;
padding: 10px 2px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
/* First, and in the quieter weight: the date is context for the line, not what is being read. */
&__date {
min-width: 96px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
&__code {
font-weight: 600;
min-width: 84px;
@@ -393,3 +399,90 @@ $primary: #0f766e;
font-variant-numeric: tabular-nums;
}
}
/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */
.dte-expected--note {
font-size: 13px;
opacity: 0.65;
margin-top: 4px;
}
.dte-apply-receipt {
margin-left: 8px;
}
/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */
.dte-money {
display: flex;
gap: 12px;
margin: 8px 0 12px;
flex-wrap: wrap;
&__box {
flex: 1;
min-width: 160px;
padding: 10px 14px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-variant-numeric: tabular-nums;
strong {
font-size: 18px;
}
&--due {
border-color: rgb(198 40 40 / 40%);
background: rgb(198 40 40 / 4%);
}
}
&__label {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.dte-subhead {
margin: 16px 0 4px;
font-size: 14px;
font-weight: 600;
}
.dte-pending-row--day {
cursor: pointer;
}
.dte-day-lines {
margin-left: 24px;
max-height: 280px;
overflow-y: auto;
border-left: 3px solid rgb(25 118 210 / 30%);
padding-left: 10px;
}
.dte-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: rgb(237 108 2 / 12%);
color: #b45309;
&--ok {
background: rgb(46 125 50 / 12%);
color: #2e7d32;
}
}
.dte-list-arrow {
cursor: pointer;
}
.dte-same-party {
color: #c62828;
font-weight: 600;
}
@@ -12,17 +12,25 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } fr
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatIconModule } from '@angular/material/icon';
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';
import {
ApplyDdsReceiptDialogComponent,
EntryDay,
groupByDay
} from './apply-dds-receipt-dialog/apply-dds-receipt-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';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Daily Transaction Entry component.
@@ -45,7 +53,11 @@ import { Dates } from 'app/core/utils/dates';
selector: 'mifosx-daily-transaction-entry',
templateUrl: './daily-transaction-entry.component.html',
styleUrls: ['./daily-transaction-entry.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
imports: [
...STANDALONE_SHARED_IMPORTS,
MatAutocompleteModule,
MatIconModule
]
})
export class DailyTransactionEntryComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@@ -301,9 +313,11 @@ export class DailyTransactionEntryComponent implements OnInit {
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'}`
);
row
.get('description')
?.setValue(
`${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}`
);
this.recalculateRowBalance(this.collections.length - 1);
});
}
@@ -373,10 +387,11 @@ export class DailyTransactionEntryComponent implements OnInit {
}
/**
* Reads what this agent has outstanding for the date on the form.
* Reads everything this agent has outstanding up to the date on the form, customer by customer.
*
* 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.
* Not only that day's: what was written on earlier days and not yet handed in is still owed, and a
* clerk checking the round should see it in one list rather than open every date in turn. Each line
* carries its own date. Asked again whenever the agent or the date changes.
*/
loadPendingEntries(): void {
const agentId = this.entryForm.get('agentId')?.value;
@@ -387,10 +402,12 @@ export class DailyTransactionEntryComponent implements OnInit {
}
this.pendingLoading = true;
const on = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
this.organizationService.getAgentCollectionDay(agentId, on).subscribe({
const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
this.organizationService.getAgentPendingCollections(agentId).subscribe({
next: (summary: any) => {
this.pendingEntries = (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING');
this.pendingEntries = (summary?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo
);
this.pendingLoading = false;
},
error: () => {
@@ -400,6 +417,138 @@ export class DailyTransactionEntryComponent implements OnInit {
});
}
/** The picked agent's own GL account code - where the cashier receipts their cash. */
agentGlCode: string | null = null;
agentGlName: string | null = null;
// ── Agent Tran: the agent's money, owed and received ──────────────────────────────────────
//
// The agent's cash reaches the bank at the cashier's counter, never on this screen. What Agent Tran
// shows is where that stands: what the agent owes on everything they wrote down, what the cashier has
// taken in against it, and what is left - and from here the receipts are applied to the customers.
/** What the agent still has outstanding up to the date on the form, a day at a time. */
agentDays: EntryDay[] = [];
/** DDS receipts on the agent's GL account not yet applied, including those still with the manager. */
agentReceipts: any[] = [];
agentMoneyLoading = false;
/** The day whose lines are open in the Agent Tran list. */
agentOpenDay: string | null = null;
get agentOwed(): number {
return this.agentDays.reduce((sum, day) => sum + day.total, 0);
}
get agentReceived(): number {
return this.agentReceipts.filter((r) => r.status === 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
get agentWaiting(): number {
return this.agentReceipts.filter((r) => r.status !== 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
/**
* What is still to be taken from the agent for this date - never below nothing. The receipts are
* not tied to a date (cash often reaches the counter the day after the round), so more received than
* this date's entries is not a debt the other way; it is cash for another day, said so separately.
*/
get agentBalance(): number {
return Math.max(0, Math.round((this.agentOwed - this.agentReceived) * 100) / 100);
}
/** Received cash beyond this date's entries - most likely for another day's. */
get agentReceivedBeyondDay(): number {
return Math.max(0, Math.round((this.agentReceived - this.agentOwed) * 100) / 100);
}
loadAgentMoney(): void {
const agentId = this.entryForm.get('agentId')?.value;
const date = this.entryForm.get('transactionDate')?.value;
this.agentOpenDay = null;
if (!agentId || !date) {
this.agentDays = [];
this.agentReceipts = [];
return;
}
this.agentMoneyLoading = true;
// Everything still outstanding up to the date on the screen, whatever day it was written: an agent
// who comes in after three days brings three days' cash, and the counter has to see all of it to
// know what to take.
const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
this.organizationService.getAgentPendingCollections(agentId).subscribe({
next: (summary: any) => {
this.agentDays = groupByDay(
(summary?.entries || []).filter((entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo)
);
this.agentMoneyLoading = false;
},
error: () => {
this.agentDays = [];
this.agentMoneyLoading = false;
}
});
this.organizationService.getAgentCashReceipts(agentId, undefined, true).subscribe({
next: (rows: any[]) => (this.agentReceipts = rows || []),
error: () => (this.agentReceipts = [])
});
}
toggleAgentDay(date: string): void {
this.agentOpenDay = this.agentOpenDay === date ? null : date;
}
/**
* Opens Apply DDS Receipt: the cashier's DDS receipt on this agent's GL account, applied to what the
* agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays
* what it is - what to take from the agent - and the applying is done in one deliberate place.
*/
openApplyReceipt(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
return;
}
const picked = this.agents.find((agent: any) => agent.id === agentId);
this.dialog
.open(ApplyDdsReceiptDialogComponent, {
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
agentId,
agentCode: picked?.agentCode || '',
agentName: this.agentInfo.name || picked?.agentNameId || '',
glCode: this.agentGlCode,
// The day chosen on the screen: its entries are what the receipts settle.
entryDate: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd')
}
})
.afterClosed()
.subscribe((result: any) => {
if (!result) {
return;
}
const money = Number(result.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 });
const vouchers: string[] = result.vouchers || [];
if (heldForApproval(result.response)) {
announceHeldForApproval(result.response, `Applying ${money}`, this.organizationService, this.alertService);
this.loadPendingEntries();
return;
}
this.alertService.alert({
type: 'Success',
message:
`${vouchers.length === 1 ? 'Voucher' : 'Vouchers'} ${vouchers.join(', ')} applied - ` +
`${result.entries} ${result.entries === 1 ? 'entry' : 'entries'}, ${money}`
});
this.loadPendingEntries();
this.loadTodaysEntries();
this.loadAgentMoney();
});
}
get isPartyTran(): boolean {
return this.entryForm.get('transactionType').value === 'Party Tran';
}
@@ -487,6 +636,113 @@ export class DailyTransactionEntryComponent implements OnInit {
next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)),
error: () => (this.agents = [])
});
// Withdraw Deposite: the open accounts either side can be picked from, and a code typed or picked
// fills in whose account it is and what it holds, so the clerk checks the transfer against both.
this.organizationService.getParties().subscribe({
next: (parties: any) => {
const all = Array.isArray(parties) ? parties : parties?.pageItems || [];
this.openParties = all.filter((party: any) => party.status === 'ACTIVE');
this.fillTransferParty('creditor');
this.fillTransferParty('debit');
},
error: () => (this.openParties = [])
});
this.entryForm.get('creditorPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('creditor'));
this.entryForm.get('debitPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('debit'));
}
/** Open DDS accounts, for the two sides of a Withdraw Deposite. */
openParties: any[] = [];
/**
* The open accounts either side can be picked from. The server moves money only between two
* accounts of the agent on the entry, so once an agent is chosen only that agent's accounts are offered.
*/
private get transferableParties(): any[] {
const agentId = Number(this.entryForm.get('agentId')?.value) || null;
return agentId ? this.openParties.filter((party: any) => Number(party.agentId) === agentId) : this.openParties;
}
/** The open accounts matching what has been typed on one side - by code, name or account number, any case. */
transferPartyOptions(side: 'creditor' | 'debit'): any[] {
const typed = String(this.entryForm.get(side + 'PartyCode')?.value || '')
.trim()
.toLowerCase();
const other = String(this.entryForm.get(this.otherSide(side) + 'PartyCode')?.value || '')
.trim()
.toUpperCase();
// Once an account is picked the whole list comes back, so another can be chosen without clearing the box.
const picked = this.transferableParties.some((party: any) => String(party.partyCode).toLowerCase() === typed);
return this.transferableParties
.filter((party: any) => String(party.partyCode).toUpperCase() !== other)
.filter(
(party: any) =>
!typed ||
picked ||
String(party.partyCode).toLowerCase().includes(typed) ||
String(party.partyName || '')
.toLowerCase()
.includes(typed) ||
String(party.accountNo || '')
.toLowerCase()
.includes(typed)
)
.slice(0, 50);
}
/** True when the same account has been put on both sides. */
get transferToItself(): boolean {
const creditor = String(this.entryForm.get('creditorPartyCode')?.value || '')
.trim()
.toUpperCase();
const debit = String(this.entryForm.get('debitPartyCode')?.value || '')
.trim()
.toUpperCase();
return this.isWithdrawDeposite && !!creditor && creditor === debit;
}
/**
* When the box is left with a name or part of one typed in and only one account matches it, that
* account is taken - so typing "amit" and moving on picks Amit's account without opening the list.
*/
settleTransferParty(side: 'creditor' | 'debit'): void {
const control = this.entryForm.get(side + 'PartyCode');
const typed = String(control?.value || '').trim();
if (!typed || this.partyByCode(typed)) {
return;
}
const matches = this.transferPartyOptions(side);
if (matches.length === 1) {
control?.setValue(matches[0].partyCode);
}
}
private otherSide(side: 'creditor' | 'debit'): 'creditor' | 'debit' {
return side === 'creditor' ? 'debit' : 'creditor';
}
private partyByCode(code: string): any {
const wanted = String(code || '')
.trim()
.toUpperCase();
return wanted ? this.openParties.find((each: any) => String(each.partyCode).toUpperCase() === wanted) : undefined;
}
/**
* Fills a side's name and balance from the code on it, and clears them when it matches no account.
* The code is matched in any case and put back as the account has it, so "pt0002" becomes PT0002.
*/
private fillTransferParty(side: 'creditor' | 'debit'): void {
const control = this.entryForm.get(side + 'PartyCode');
const party = this.partyByCode(control?.value);
if (party && control?.value !== party.partyCode) {
control?.setValue(party.partyCode, { emitEvent: false });
}
this.entryForm.get(side + 'PartyName')?.setValue(party ? party.partyName : '', { emitEvent: false });
this.entryForm
.get(side + 'PartyBalance')
?.setValue(party ? Number(party.currentBalance || 0) : 0, { emitEvent: false });
}
/**
@@ -527,6 +783,9 @@ export class DailyTransactionEntryComponent implements OnInit {
if (!agentId) {
this.agentParties = [];
this.pendingEntries = [];
this.agentGlCode = null;
this.agentDays = [];
this.agentReceipts = [];
return;
}
@@ -542,10 +801,20 @@ export class DailyTransactionEntryComponent implements OnInit {
guarantor2: guarantors[1]?.guarantorName || '',
currentBalance: agent.agentCurrentBalance || 0
};
this.agentGlCode = agent.glAccountCode || null;
this.agentGlName = agent.glAccountName || null;
},
error: () => {
this.agentLookupFailed = true;
this.agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 };
this.agentInfo = {
name: '',
address: '',
city: '',
accountNo: '',
guarantor1: '',
guarantor2: '',
currentBalance: 0
};
}
});
@@ -559,6 +828,7 @@ export class DailyTransactionEntryComponent implements OnInit {
this.loadTodaysEntries();
this.loadPendingEntries();
this.loadAgentMoney();
}
/** This agent's collections already recorded for the date on the screen. */
@@ -574,8 +844,8 @@ export class DailyTransactionEntryComponent implements OnInit {
next: (transactions: any[]) => {
this.todaysEntries = (transactions || []).filter(
(transaction: any) =>
transaction.transactionType === 'PARTY_TRAN'
&& this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate
transaction.transactionType === 'PARTY_TRAN' &&
this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate
);
},
error: () => (this.todaysEntries = [])
@@ -672,36 +942,18 @@ export class DailyTransactionEntryComponent implements OnInit {
* warn about.
*/
save(): void {
if (!this.isPartyTran) {
this.postTransaction();
// A Party Tran cannot be saved from here at all. The screen shows the agent's own entries so
// the cash can be counted against them; taking that cash in is done where the branch takes cash
// in, and two ways to credit the same customers is one way too many. The button is not rendered
// for this type either - this is the same rule said again where it cannot be bypassed.
// Agent Tran is not saved from here either any more. The agent's cash is taken in at the cashier's
// counter, and a second way to take the same cash in is one way too many; Agent Tran shows where it
// stands and applies the cashier's receipts.
if (this.isPartyTran || this.isAgentTran) {
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();
}
});
this.postTransaction();
}
private postTransaction(): void {
@@ -743,10 +995,17 @@ export class DailyTransactionEntryComponent implements OnInit {
// 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(() => {
this.organizationService.createPartyTransaction(payload).subscribe((response: any) => {
// 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 });
//
// With maker-checker on, the request comes back with a command id and nothing done: it is
// waiting for a checker, and saying it was posted would be telling the clerk the money moved.
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
this.alertService.alert({
type: 'Success',
message: awaitingApproval ? `Sent for approval: ${posted}. Nothing moves until a checker approves it.` : posted
});
this.collections.clear();
this.partyInfo = null;
@@ -794,8 +1053,18 @@ export class DailyTransactionEntryComponent implements OnInit {
dateFormat: 'yyyy-MM-dd',
locale: this.settingsService.language.code
})
.subscribe(() => {
.subscribe((response: any) => {
const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 });
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${count} ${count === 1 ? 'collection' : 'collections'} for ${agent} (${money})`,
this.organizationService,
this.alertService
);
this.loadPendingEntries();
return;
}
this.alertService.alert({
type: 'Success',
message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money