309 lines
11 KiB
TypeScript
309 lines
11 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 { ActivatedRoute } from '@angular/router';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { catchError, of } from 'rxjs';
|
|
import { CashierService } from '../cashier.service';
|
|
|
|
/**
|
|
* Which side of the register a screen shows.
|
|
*
|
|
* 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
|
|
* so the two screens are the same component pointed at opposite sides.
|
|
*/
|
|
export type VoucherSide = 'CR' | 'DR';
|
|
|
|
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
|
interface VoucherRow {
|
|
voucherNo: string;
|
|
voucherType: string | null;
|
|
amount: number | null;
|
|
createdAt: string | null;
|
|
userId: number | null;
|
|
username: string | null;
|
|
branchName: string | null;
|
|
verificationStatus: string | null;
|
|
debitParty: string | null;
|
|
debitAccount: string | null;
|
|
creditParty: string | null;
|
|
creditAccount: string | null;
|
|
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
|
extraLines: number;
|
|
/**
|
|
* Whether a denomination breakdown was recorded against this voucher.
|
|
*
|
|
* True proves cash was counted. False does not prove the opposite: several registers pay real
|
|
* money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
|
|
*/
|
|
denominationCounted: boolean;
|
|
}
|
|
|
|
interface VoucherReport {
|
|
from: string;
|
|
to: string;
|
|
username: string | null;
|
|
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
|
canSeeAllUsers: boolean;
|
|
/** Whether the server restricted the register to cash movements. */
|
|
cashOnly: boolean;
|
|
vouchers: VoucherRow[];
|
|
count: number;
|
|
totalAmount: number;
|
|
}
|
|
|
|
/**
|
|
* The vouchers raised over a day or a month, one row each.
|
|
*
|
|
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
|
|
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-voucher-report',
|
|
templateUrl: './voucher-report.component.html',
|
|
styleUrls: [
|
|
'../cashier-shared.scss',
|
|
'./voucher-report.component.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatIconModule
|
|
]
|
|
})
|
|
export class VoucherReportComponent implements OnInit {
|
|
private service = inject(CashierService);
|
|
private route = inject(ActivatedRoute);
|
|
|
|
/** Day is the working default; a month is what you ask for at a close. */
|
|
period: 'DAY' | 'MONTH' = 'DAY';
|
|
/** Anchor date — the day itself, or any day inside the month wanted. */
|
|
anchor = this.todayIso();
|
|
usernameFilter = '';
|
|
typeFilter = '';
|
|
|
|
/** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
|
|
side: VoucherSide = 'CR';
|
|
|
|
/**
|
|
* Whether this screen is the cash register rather than the whole voucher register.
|
|
*
|
|
* A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
|
|
* clearing settlement, a cheque book fee — are left out, and everything that could have crossed
|
|
* the counter stays in. The filter errs towards keeping a row: a book entry on the register can
|
|
* be seen and recognised, whereas cash that never appears is invisible until it turns up as an
|
|
* unexplained difference at the end of the day.
|
|
*/
|
|
cashOnly = false;
|
|
|
|
report: VoucherReport | null = null;
|
|
loading = false;
|
|
error: string | null = null;
|
|
|
|
ngOnInit(): void {
|
|
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
|
|
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
|
|
this.load();
|
|
}
|
|
|
|
get isReceived(): boolean {
|
|
return this.side === 'CR';
|
|
}
|
|
|
|
get title(): string {
|
|
return this.isReceived ? 'Received Report' : 'Payment Report';
|
|
}
|
|
|
|
get subtitle(): string {
|
|
if (this.cashOnly) {
|
|
return this.isReceived
|
|
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
|
|
: 'Cash paid out over a day or a month, and who paid it — book entries left out.';
|
|
}
|
|
return this.isReceived
|
|
? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
|
|
: 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
|
|
}
|
|
|
|
/** The word for one row on this side, for counts and the empty state. */
|
|
get noun(): string {
|
|
return this.isReceived ? 'receipt' : 'payment';
|
|
}
|
|
|
|
load(): void {
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
const { from, to } = this.range();
|
|
this.service
|
|
.voucherReport(
|
|
from,
|
|
to,
|
|
this.usernameFilter.trim() || undefined,
|
|
this.typeFilter.trim() || undefined,
|
|
this.cashOnly
|
|
)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.error =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
|
err?.error?.developerMessage ??
|
|
'The voucher report could not be loaded.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: VoucherReport | null) => {
|
|
this.loading = false;
|
|
this.report = res;
|
|
});
|
|
}
|
|
|
|
/** The period the anchor date names: that day, or the whole calendar month around it. */
|
|
private range(): { from: string; to: string } {
|
|
if (this.period === 'DAY') {
|
|
return { from: this.anchor, to: this.anchor };
|
|
}
|
|
const [
|
|
year,
|
|
month
|
|
] = this.anchor.split('-').map(Number);
|
|
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
|
|
// ended correctly without a table of month lengths.
|
|
const lastDay = new Date(year, month, 0).getDate();
|
|
const mm = String(month).padStart(2, '0');
|
|
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
|
|
}
|
|
|
|
private todayIso(): string {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* The rows belonging to this screen's side.
|
|
*
|
|
* The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
|
|
* server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
|
|
* that takes money out gets DR. That prefix is the authoritative record of the side, so it is
|
|
* matched directly rather than re-deriving the side from the voucher type here — a new type added
|
|
* on the server then lands on the correct report with no change needed on this screen.
|
|
*/
|
|
get rows(): VoucherRow[] {
|
|
const all = this.report?.vouchers ?? [];
|
|
return all.filter((row) => this.sideOf(row) === this.side);
|
|
}
|
|
|
|
/**
|
|
* Whether a voucher has its two sides recorded against it.
|
|
*
|
|
* The registers raise a voucher when the request is made and only attach its sides once it is
|
|
* approved and posted, so a voucher raised before that step existed carries a number but no
|
|
* lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
|
|
* went nowhere.
|
|
*/
|
|
hasSides(row: VoucherRow): boolean {
|
|
return !!(row.debitParty || row.creditParty);
|
|
}
|
|
|
|
/** How many rows on this side are missing their debit/credit lines. */
|
|
get missingSidesCount(): number {
|
|
return this.rows.filter((row) => !this.hasSides(row)).length;
|
|
}
|
|
|
|
/** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
|
|
private sideOf(row: VoucherRow): VoucherSide | null {
|
|
const no = (row.voucherNo ?? '').toUpperCase();
|
|
if (no.startsWith('CR/')) {
|
|
return 'CR';
|
|
}
|
|
if (no.startsWith('DR/')) {
|
|
return 'DR';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** How many rows on this side — not the server's count, which spans both. */
|
|
get count(): number {
|
|
return this.rows.length;
|
|
}
|
|
|
|
/** Total for this side only. */
|
|
get totalAmount(): number {
|
|
return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
|
}
|
|
|
|
/**
|
|
* The part of this side's total whose notes and coins were actually counted.
|
|
*
|
|
* Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
|
|
* because the register that raised it records none. So this is the confirmed part of the cash,
|
|
* not the whole of it, and the gap is reported rather than treated as book entries.
|
|
*/
|
|
get countedTotal(): number {
|
|
return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
|
}
|
|
|
|
/** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
|
|
get uncountedCount(): number {
|
|
return this.rows.filter((row) => !row.denominationCounted).length;
|
|
}
|
|
|
|
/** How much of this side's total has no note-and-coin breakdown behind it. */
|
|
get uncountedTotal(): number {
|
|
return this.totalAmount - this.countedTotal;
|
|
}
|
|
|
|
/**
|
|
* Vouchers whose number carries neither prefix, so they appear on neither report.
|
|
*
|
|
* Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
|
|
* register is exactly the kind of thing a cashier must be told about, not left to discover when
|
|
* the two reports do not add up to the day.
|
|
*/
|
|
get unclassifiedCount(): number {
|
|
const all = this.report?.vouchers ?? [];
|
|
return all.filter((row) => this.sideOf(row) === null).length;
|
|
}
|
|
|
|
/** Totals per user, for the summary a manager reads before the rows. */
|
|
get byUser(): { username: string; count: number; total: number }[] {
|
|
const rows = this.rows;
|
|
const tally = new Map<string, { username: string; count: number; total: number }>();
|
|
for (const row of rows) {
|
|
const key = row.username ?? '—';
|
|
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
|
|
entry.count += 1;
|
|
entry.total += row.amount ?? 0;
|
|
tally.set(key, entry);
|
|
}
|
|
return [...tally.values()].sort((a, b) => b.total - a.total);
|
|
}
|
|
|
|
/** Totals per voucher type — deposits against withdrawals against journals. */
|
|
get byType(): { type: string; count: number; total: number }[] {
|
|
const rows = this.rows;
|
|
const tally = new Map<string, { type: string; count: number; total: number }>();
|
|
for (const row of rows) {
|
|
const key = row.voucherType ?? '—';
|
|
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
|
|
entry.count += 1;
|
|
entry.total += row.amount ?? 0;
|
|
tally.set(key, entry);
|
|
}
|
|
return [...tally.values()].sort((a, b) => b.total - a.total);
|
|
}
|
|
|
|
print(): void {
|
|
window.print();
|
|
}
|
|
}
|