379 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|