Files
cbsnexora/src/app/cashier/day-book/day-book.component.ts
T

379 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 { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
*
* Only counter business appears in the day book. Cash moved between the vault and the drawers is
* the bank shifting its own money, not a customer transaction, and it is read on the Cashier
* Position and Closing Cash Report where the drawer itself is the subject.
*/
type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
accountNo: string | null;
accountType: string | null;
party: string | null;
amount: number | null;
paymentTypeName: string | null;
description: string | null;
status: string;
handledBy: string | null;
approvedBy: string | null;
txnId: string | null;
submittedAt: string | null;
actionedAt: string | null;
}
/**
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
* disbursements, collections and bank transfers, with the day's credit and debit totals.
*/
@Component({
selector: 'mifosx-day-book',
templateUrl: './day-book.component.html',
styleUrls: [
'../cashier-shared.scss',
'./day-book.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class DayBookComponent implements OnInit {
private service = inject(CashierService);
private router = inject(Router);
/**
* The heading, taken from the route the screen was opened by.
*
* One screen serves three menus, and only the Branch Ops reading is a different thing: there it
* is the branch's book with the verification sheet an officer signs off, rather than one desk's
* view of the day.
*/
get title(): string {
return this.router.url.includes('/branch-ops/') ? 'Day Book & Officer Verification' : 'Day Book';
}
date = '';
side: '' | 'CR' | 'DR' = '';
status = '';
search = '';
rows: DayBookRow[] = [];
creditTotal = 0;
debitTotal = 0;
netTotal = 0;
loading = false;
loaded = false;
errorMsg = '';
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.dayBook(this.date, this.side || undefined, this.status || undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.rows ?? [];
this.creditTotal = Number(res?.creditTotal ?? 0);
this.debitTotal = Number(res?.debitTotal ?? 0);
this.netTotal = Number(res?.netTotal ?? 0);
});
}
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/**
* The day's rows split by where they came from, in the order they are read.
*
* Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
* section is dropped rather than shown as a heading over nothing.
*
* A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
* heading says which register the entry belongs to, and the By column still names the person.
*/
get sections(): {
key: DayBookSection;
label: string;
hint: string;
rows: DayBookRow[];
credit: number;
debit: number;
}[] {
const order: { key: DayBookSection; label: string; hint: string }[] = [
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' },
// Postings that reached the ledger without a request behind them: interest the nightly run
// credited, a charge taken from a balance, a manual journal. These are the day's work too,
// and an officer accounting for the day cannot do it from the counter registers alone.
{
key: 'SYSTEM',
label: 'System & Journal',
hint: 'Interest, charges and journals posted without a counter request'
}
];
return order
.map((s) => {
const rows = this.filteredRows.filter((r) => r.section === s.key);
return {
...s,
rows,
credit: rows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((t, r) => t + Number(r.amount ?? 0), 0),
debit: rows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
};
})
.filter((s) => s.rows.length > 0);
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.voucherNo,
r.accountNo,
r.party,
r.kind,
r.paymentTypeName,
r.handledBy,
r.description
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a search or side filter still reconciles. */
get shownCredit(): number {
return this.filteredRows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
get shownDebit(): number {
return this.filteredRows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
/** Only approved money is real money — a pending or rejected voucher must not move the totals.
* The three source registers don't share one terminal status string: deposits and disbursements
* land on APPROVED, but a clerk withdrawal's own finished state is WITHDRAWN — missing it here
* meant a completed withdrawal moved real money out yet the DR total stayed at zero. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN';
}
/**
* The day totalled per person, for the officer who signs the book off.
*
* A branch's day book is read two ways. Entry by entry, to trace one voucher — which the sections
* below already give. And person by person, to ask what each member of staff handled and whether
* their column adds up: that is the reconciliation a principal officer actually performs, and
* without it they are adding a day of rows in their head.
*
* Built from whoever appears in the day's own rows rather than from a list of staff, so it needs
* no configuring: one person working alone gets one line, five people get five, and somebody who
* handled nothing today is simply absent instead of showing as a row of zeros.
*
* Only approved money counts, the same rule the section totals use — a pending request is not
* cash anybody is answerable for yet. Pending and rejected rows are still counted as ITEMS, so a
* person whose work is all awaiting approval does not vanish from the sheet.
*
* Ordered by the largest turnover first: the columns worth checking hardest are the ones that
* moved the most money.
*/
get byUser(): {
name: string;
credit: number;
debit: number;
net: number;
items: number;
pending: number;
}[] {
const map = new Map<string, { name: string; credit: number; debit: number; items: number; pending: number }>();
for (const r of this.filteredRows) {
// Rows the registers could not attribute are grouped rather than dropped: an unattributed
// amount is exactly what an officer needs to see, and hiding it would leave the sheet's own
// total disagreeing with the day's.
const name = (r.handledBy ?? '').trim() || 'Unattributed';
const entry = map.get(name) ?? { name, credit: 0, debit: 0, items: 0, pending: 0 };
entry.items++;
if (this.countsAsMoney(r)) {
const amount = Number(r.amount ?? 0);
if (r.side === 'CR') {
entry.credit += amount;
} else {
entry.debit += amount;
}
} else {
entry.pending++;
}
map.set(name, entry);
}
return Array.from(map.values())
.map((e) => ({ ...e, net: e.credit - e.debit }))
.sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
}
/** The sheet's own totals, which must equal the day's — that is what makes it verifiable. */
get byUserTotals(): { credit: number; debit: number; net: number; items: number; pending: number } {
return this.byUser.reduce(
(t, u) => ({
credit: t.credit + u.credit,
debit: t.debit + u.debit,
net: t.net + u.net,
items: t.items + u.items,
pending: t.pending + u.pending
}),
{ credit: 0, debit: 0, net: 0, items: 0, pending: 0 }
);
}
/**
* Approved and paid out are not the same state, and the day book is where the difference matters:
* an approved withdrawal is a promise, a withdrawn one is money gone. Both used to render in the
* same green, so a reader could not tell from the badge whether the customer had actually been
* handed the cash — the one thing this page is read to find out.
*/
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'WITHDRAWN':
case 'COMPLETED':
case 'CLOSED':
// Cash has left the counter. The darker, filled badge — the final state.
return 'db-badge db-badge--done';
case 'APPROVED':
// Passed, but nobody has handed anything over yet.
return 'db-badge db-badge--ok';
case 'PENDING':
return 'db-badge db-badge--pending';
case 'REJECTED':
return 'db-badge db-badge--rejected';
default:
return 'db-badge';
}
}
/** Time of day only — the date is already the page's subject. */
timeOf(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
exportCsv(): void {
const header = [
'Section',
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.section,
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
this.timeOf(r.submittedAt)
]
// Quote every field and double any embedded quote: names and narrations routinely
// contain commas, which would otherwise split into extra columns.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `day-book-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
}