Files
cbsnexora/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts
T

352 lines
12 KiB
TypeScript

/**
* 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;
}