352 lines
13 KiB
TypeScript
352 lines
13 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' | 'ALL';
|
||
|
||
/** 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;
|
||
/** The business date the posting belongs to, which is not always the day it was written. */
|
||
ledgerDate?: string | null;
|
||
/** True when the voucher was written after the day it posts against had already closed. */
|
||
postedAfterClose?: boolean;
|
||
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;
|
||
|
||
/**
|
||
* Whether this register is the counter's own — only the people who hold a drawer.
|
||
*
|
||
* These two screens reconcile the counter: what came in over it and what went out. Everyone
|
||
* else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here
|
||
* it buried the cashier rows the registers exist for and left the totals matching no drawer.
|
||
*/
|
||
cashiersOnly = false;
|
||
|
||
/**
|
||
* Whether this register is the system's own postings rather than anybody's work.
|
||
*
|
||
* Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed
|
||
* straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them —
|
||
* it is scoped to the reader — and the two counter registers deliberately exclude them. That left
|
||
* no screen answering "what did the system post last night".
|
||
*/
|
||
systemOnly = 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.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true;
|
||
this.systemOnly = this.route.snapshot.data['systemOnly'] === true;
|
||
this.load();
|
||
}
|
||
|
||
get isReceived(): boolean {
|
||
return this.side === 'CR';
|
||
}
|
||
|
||
get title(): string {
|
||
if (this.systemOnly) {
|
||
return 'System Vouchers';
|
||
}
|
||
return this.isReceived ? 'Received Report' : 'Payment Report';
|
||
}
|
||
|
||
get subtitle(): string {
|
||
if (this.systemOnly) {
|
||
return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.';
|
||
}
|
||
// Said plainly, because a register that silently omits the reader's own entries is worse than
|
||
// one that explains why: their work is on My Vouchers, and the line points them there.
|
||
if (this.cashiersOnly) {
|
||
return this.isReceived
|
||
? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.'
|
||
: 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.';
|
||
}
|
||
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 {
|
||
if (this.systemOnly) {
|
||
return 'posting';
|
||
}
|
||
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,
|
||
this.cashiersOnly,
|
||
this.systemOnly
|
||
)
|
||
.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 this.side === 'ALL' ? all : 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();
|
||
}
|
||
}
|