/** * 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(); 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); } }