/** * 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); 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(); pickedReceipts = new Set(); /** 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(); 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; }