feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -10,10 +10,8 @@
|
||||
<header class="pg-head vr-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||
<h1 class="pg-title">Voucher Report</h1>
|
||||
<p class="pg-sub">
|
||||
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
|
||||
</p>
|
||||
<h1 class="pg-title">{{ title }}</h1>
|
||||
<p class="pg-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
@@ -42,10 +40,8 @@
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Run
|
||||
</button>
|
||||
@if (report && report.count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
@if (report && count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()"><mat-icon>print</mat-icon> Print</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
@@ -64,8 +60,7 @@
|
||||
@if (report.from !== report.to) {
|
||||
— {{ report.to | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
|
||||
· ₹{{ report.totalAmount | number: '1.2-2' }}
|
||||
· {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
|
||||
@if (report.username) {
|
||||
· {{ report.username }}
|
||||
}
|
||||
@@ -74,10 +69,46 @@
|
||||
@if (!report.canSeeAllUsers) {
|
||||
<span class="vr-scope-note">Your own vouchers</span>
|
||||
}
|
||||
<!-- The total on a cash register means something narrower than the whole voucher book, so the
|
||||
screen says which one it is instead of leaving the figure ambiguous. -->
|
||||
@if (cashOnly) {
|
||||
<span class="vr-scope-note">Cash only</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (report.count === 0) {
|
||||
<div class="pg-empty">No vouchers were raised in this period.</div>
|
||||
<!-- A voucher with no CR/DR prefix belongs to neither report. Say so, so the two reports not
|
||||
adding up to the day's total is explained rather than left to be puzzled over. -->
|
||||
@if (unclassifiedCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ unclassifiedCount }} voucher{{ unclassifiedCount === 1 ? '' : 's' }} in this period
|
||||
{{ unclassifiedCount === 1 ? 'carries' : 'carry' }} no CR or DR prefix, so
|
||||
{{ unclassifiedCount === 1 ? 'it appears' : 'they appear' }} on neither the Received nor the Payment report.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Which part of the total has notes and coins counted behind it. Shown on the cash register
|
||||
as well as the full one: a voucher with no breakdown is still real money, so it stays in
|
||||
the total, and what the drawer can be checked against is stated instead of assumed. -->
|
||||
@if (uncountedCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ uncountedCount }} of these {{ noun }}s {{ uncountedCount === 1 ? 'has' : 'have' }} no denomination breakdown
|
||||
recorded, totalling ₹{{ uncountedTotal | number: '1.2-2' }}. Some registers pay out without counting notes
|
||||
against the voucher, so these are still cash — ₹{{ countedTotal | number: '1.2-2' }} of the total has a
|
||||
breakdown behind it.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Explains the blank Debit/Credit cells instead of leaving them looking like lost data. -->
|
||||
@if (missingSidesCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ missingSidesCount }} of these {{ noun }}s {{ missingSidesCount === 1 ? 'has' : 'have' }} no debit or credit
|
||||
recorded. The sides are attached when a request is approved, so vouchers raised before that step was added keep
|
||||
their number and amount but show no accounts. Newly raised {{ noun }}s carry both sides.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (count === 0) {
|
||||
<div class="pg-empty">No {{ noun }}s were raised in this period.</div>
|
||||
} @else {
|
||||
<div class="vr-summaries">
|
||||
<div class="pg-card">
|
||||
@@ -129,7 +160,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Vouchers</div>
|
||||
<div class="pos-section-label">{{ isReceived ? 'Receipts' : 'Payments' }}</div>
|
||||
<table class="pg-table vr-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -143,33 +174,39 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of report.vouchers; track row.voucherNo) {
|
||||
@for (row of rows; track row.voucherNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
|
||||
{{ row.voucherNo }}
|
||||
</a>
|
||||
{{ row.voucherNo }}
|
||||
@if (row.verificationStatus === 'EXCEPTION') {
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher"
|
||||
>error_outline</mat-icon
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.voucherType || '—' }}</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
<!-- A voucher raised before its sides were recorded has neither. Say that once,
|
||||
across both cells, rather than showing two dashes that read as "nowhere". -->
|
||||
@if (!hasSides(row)) {
|
||||
<td colspan="2" class="vr-nosides">Sides not recorded</td>
|
||||
} @else {
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.username || '—' }}</td>
|
||||
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||
|
||||
@@ -36,16 +36,6 @@
|
||||
}
|
||||
|
||||
.vr-rows {
|
||||
.vr-link {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-flag {
|
||||
margin-left: 4px;
|
||||
font-size: 15px;
|
||||
@@ -65,6 +55,13 @@
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// Spans the Debit and Credit cells for a voucher raised before its sides were recorded.
|
||||
.vr-nosides {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a report should put the register on paper — the filters are how you reached it. */
|
||||
@@ -72,9 +69,4 @@
|
||||
.vr-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vr-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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