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
@@ -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