DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes
This commit is contained in:
+381
@@ -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 & 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>
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
+351
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user