feat(cashier): add end-of-day report, my work, and cashier updates

This commit is contained in:
2026-09-08 10:38:59 +05:30
parent fbce73fd17
commit 035b11fa96
20 changed files with 3146 additions and 124 deletions
@@ -170,6 +170,15 @@
<th>Credit</th>
<th class="num">Amount</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
the business date. A nightly run finishing after midnight writes today's voucher
for yesterday's work, so a reader opening this screen today is looking at last
night's postings — and without the ledger date they would take the timestamp for
the day the money moved. -->
@if (systemOnly) {
<th>Ledger date</th>
}
<th>Time</th>
</tr>
</thead>
@@ -209,6 +218,14 @@
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
{{ row.ledgerDate ? (row.ledgerDate | date: 'dd MMM yyyy') : '—' }}
@if (row.postedAfterClose) {
<span class="vr-priorflag" title="Posted after that day had closed">after close</span>
}
</td>
}
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
</tr>
}
@@ -70,3 +70,25 @@
display: none !important;
}
}
/* The ledger date on the system register. Marked when it differs from the day the voucher was
written: that is the whole reason the column exists, and a date the reader has to compare against
the Time column by eye is a date they will misread. */
.vr-ledgerdate {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.vr-ledgerdate--prior {
color: #5b21b6;
}
.vr-priorflag {
display: block;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7c3aed;
}
@@ -20,7 +20,7 @@ import { CashierService } from '../cashier.service';
* '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';
export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
@@ -28,6 +28,10 @@ interface VoucherRow {
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;
@@ -105,6 +109,25 @@ export class VoucherReportComponent implements OnInit {
*/
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;
@@ -112,6 +135,8 @@ export class VoucherReportComponent implements OnInit {
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();
}
@@ -120,10 +145,23 @@ export class VoucherReportComponent implements OnInit {
}
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.'
@@ -136,6 +174,9 @@ export class VoucherReportComponent implements OnInit {
/** 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';
}
@@ -150,7 +191,9 @@ export class VoucherReportComponent implements OnInit {
to,
this.usernameFilter.trim() || undefined,
this.typeFilter.trim() || undefined,
this.cashOnly
this.cashOnly,
this.cashiersOnly,
this.systemOnly
)
.pipe(
catchError((err: any) => {
@@ -199,7 +242,7 @@ export class VoucherReportComponent implements OnInit {
*/
get rows(): VoucherRow[] {
const all = this.report?.vouchers ?? [];
return all.filter((row) => this.sideOf(row) === this.side);
return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side);
}
/**