feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -9,11 +9,19 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
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;
|
||||
@@ -30,6 +38,13 @@ interface VoucherRow {
|
||||
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 {
|
||||
@@ -38,6 +53,8 @@ interface VoucherReport {
|
||||
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;
|
||||
@@ -60,12 +77,12 @@ interface VoucherReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
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';
|
||||
@@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit {
|
||||
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)
|
||||
.voucherReport(
|
||||
from,
|
||||
to,
|
||||
this.usernameFilter.trim() || undefined,
|
||||
this.typeFilter.trim() || undefined,
|
||||
this.cashOnly
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
@@ -125,9 +188,95 @@ export class VoucherReportComponent implements OnInit {
|
||||
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.report?.vouchers ?? [];
|
||||
const rows = this.rows;
|
||||
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.username ?? '—';
|
||||
@@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit {
|
||||
|
||||
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||||
get byType(): { type: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const rows = this.rows;
|
||||
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.voucherType ?? '—';
|
||||
|
||||
Reference in New Issue
Block a user