352 lines
12 KiB
TypeScript
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;
|
|
}
|