Files
cbsnexora/src/app/cashier/day-book/day-book.component.ts
T
niteshandClaude Sonnet 5.5 ab8ed7e67e feat(vouchers): voucher correction screens and a correction status on the voucher registers
A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager
approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until
the final approval.

- Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the
  unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side
  and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the
  request and deposits separately.
- Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under
  Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are
  added to the same tab bar.
- A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup)
  says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason.
- The deposit taken off the wrong account no longer shows on its passbook or transaction list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:28:27 +05:30

387 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';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* 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;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: 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,
VoucherStatusComponent
]
})
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 = businessToday();
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);
}
}