feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
@@ -0,0 +1,393 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
<h1 class="pg-title">Day Book</h1>
<p class="pg-sub">
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
transfers, with credit and debit totals.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>Date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Filters -->
<div class="db-filters">
<label class="pg-field">
<span>Side</span>
<select [(ngModel)]="side" (change)="load()">
<option value="">All</option>
<option value="CR">CR · Credit (money in)</option>
<option value="DR">DR · Debit (money out)</option>
</select>
</label>
<label class="pg-field">
<span>Status</span>
<select [(ngModel)]="status" (change)="load()">
<option value="">All</option>
<option value="APPROVED">Approved</option>
<option value="PENDING">Pending</option>
<option value="REJECTED">Rejected</option>
</select>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
</label>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
flag raised for investigation is useless if someone has to scroll to find it. -->
@if (!loading && exceptionRows.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
</strong>
@if (resolvedExceptionCount > 0) {
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
}
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
</button>
</div>
@for (e of exceptionRows; track e.voucherNo) {
<div class="db-exceptions__item">
<span class="pg-chip">{{ e.voucherNo }}</span>
<span class="db-exceptions__amt">
System ₹{{ e.amount | number: '1.2-2' }}
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
<strong class="db-exceptions__diff"
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
>
}
</span>
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
</div>
}
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && rows.length === 0) {
<div class="pg-empty">
<mat-icon>receipt_long</mat-icon>
<p>No vouchers were raised on {{ date }}.</p>
</div>
}
@if (!loading && filteredRows.length > 0) {
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Credit · CR (money in)</span>
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Debit · DR (money out)</span>
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
>
<span class="closing-summary__label">Net</span>
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
</div>
</div>
<p class="db-note">
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
</p>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>By</th>
<th>Approved By</th>
<th>Slip Check</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>
@if (r.verifyStatus === 'VERIFIED') {
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
>✓ MATCHED</span
>
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
<span
class="db-badge db-badge--ok"
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
>
✓ RESOLVED
</span>
} @else if (r.verifyStatus === 'EXCEPTION') {
<span
class="db-badge db-badge--rejected"
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
>
⚠ EXCEPTION
</span>
} @else {
<span class="db-muted">Not checked</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
<td class="db-actions">
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
<mat-icon>fact_check</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
<mat-icon>attach_file</mat-icon>
@if (r.attachmentCount > 0) {
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
}
</button>
</td>
</tr>
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
enough: it cannot be printed, exported, or read without a mouse. -->
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
<td colspan="13">
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
<strong>Exception</strong>
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
}
— {{ r.verifyRemarks }}
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
@if (r.resolutionStatus) {
<div class="db-exc-row__resolution">
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
{{ r.resolutionNotes }}
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
</div>
}
</td>
</tr>
}
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
next to the row they describe. -->
@if (verifyRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Slip check — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
</div>
<label class="pg-field">
<span>Amount written on slip</span>
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
</label>
@if (verifySlipBlank) {
<div class="db-cmp">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">—</span>
</div>
} @else if (verifyMismatch) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Match</span>
<span class="db-cmp__value">Amounts agree</span>
</div>
}
</div>
<label class="pg-field db-panel__remarks">
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
<input
type="text"
[(ngModel)]="verifyRemarks"
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
/>
</label>
@if (verifyMismatch) {
<p class="db-panel__warn">
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
change the posted amount.
</p>
}
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveVerify()"
[disabled]="verifySaving || verifySlipBlank"
>
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
</button>
<!-- Raising an exception on an already-matched voucher stays available: a
slip cleared in haste, or a difference noticed later, has to be
recordable without erasing the earlier verdict. -->
@if (!verifyMismatch) {
<button
type="button"
class="pg-btn pg-btn--warn"
(click)="raiseException()"
[disabled]="verifySaving"
>
Raise Exception
</button>
}
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
</div>
</div>
</td>
</tr>
}
<!-- Attachment panel for this voucher. -->
@if (attachRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Attachments — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="db-muted">Nothing attached to this voucher yet.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
}
</div>
@@ -0,0 +1,393 @@
/**
* 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/.
*/
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
lives here. */
.db-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* The search box takes the slack so the two selects keep their natural width. */
&__search {
flex: 1 1 260px;
}
}
.db-note {
margin: 8px 0 12px;
font-size: 11px;
color: #94a3b8;
}
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
.db-side {
display: inline-block;
min-width: 28px;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
background: #f1f5f9;
color: #475569;
&--cr {
background: #dcfce7;
color: #15803d;
}
&--dr {
background: #fee2e2;
color: #b91c1c;
}
}
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
which would fight the table's own hover state. */
.db-row--cr td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.db-row--dr td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
.db-muted {
color: #94a3b8;
font-size: 11px;
}
.db-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
&--ok {
background: #dcfce7;
color: #15803d;
}
&--pending {
background: #fef3c7;
color: #b45309;
}
&--rejected {
background: #fee2e2;
color: #b91c1c;
}
}
/* Not defined in cashier-shared.scss, so the day book carries its own. */
.pg-alert {
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
}
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
hunt for a badge. */
.db-exceptions {
margin-bottom: 14px;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
/* Pushed to the right so the count reads first. */
.pg-btn {
margin-left: auto;
height: 30px;
font-size: 12px;
}
}
&__item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
}
&__amt {
font-variant-numeric: tabular-nums;
color: #334155;
}
&__diff {
color: #b91c1c;
}
&__why {
color: #0d1b2a;
font-weight: 600;
}
}
/* The reason spelled out under its own flagged row. */
.db-exc-row td {
background: #fef2f2 !important;
border-bottom: 1px solid #fecaca !important;
color: #7f1d1d;
font-size: 12px;
padding: 8px 14px !important;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
vertical-align: -3px;
margin-right: 4px;
}
}
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
something anyone has to act on. */
.db-exc-row--resolved td {
background: #f0fdf4 !important;
border-bottom: 1px solid #bbf7d0 !important;
color: #14532d;
}
.db-exc-row__resolution {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dotted #bbf7d0;
}
.db-actions {
white-space: nowrap;
}
/* Raising an exception is a deliberate, consequential act — styled to read that way without
looking destructive, since nothing is deleted. */
.pg-btn--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
&:hover {
background: #fef3c7;
}
}
.db-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: #0d1b2a;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
at a glance, without a separate column. */
&__count {
position: absolute;
top: -5px;
right: -5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 14px;
}
}
/* Inline panel, opened under the row it belongs to. */
.db-panel-row td {
background: #f8fafc !important;
padding: 0 !important;
}
.db-panel {
padding: 14px 16px;
border-left: 3px solid #2563eb;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: #0d1b2a;
}
&__body {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
&__remarks {
display: block;
margin-top: 12px;
max-width: 640px;
}
&__warn {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
}
/* The two amounts being compared, side by side. */
.db-cmp {
padding: 8px 14px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 2px;
font-size: 15px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
&--good {
background: #f0fdf4;
border-color: #bbf7d0;
.db-cmp__value {
color: #15803d;
}
}
&--bad {
background: #fef2f2;
border-color: #fecaca;
.db-cmp__value {
color: #b91c1c;
}
}
}
.db-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.db-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
&__name {
font-weight: 700;
color: #0d1b2a;
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
@@ -0,0 +1,519 @@
/**
* 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 { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface DayBookRow {
voucherNo: string | null;
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;
/** null until someone has physically checked the slip. */
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
slipAmount: number | null;
verifyRemarks: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
/** Set once a clerk or manager has closed the exception; null while it is still open. */
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
attachmentCount: number;
}
/**
* 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);
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();
}
/** Show only the flagged vouchers — set from the exception banner. */
exceptionsOnly = false;
/**
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
* visible on its own row as history, but it is no longer something anyone has to act on.
*/
get exceptionRows(): DayBookRow[] {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
}
/** Closed exceptions, kept for the record rather than for action. */
get resolvedExceptionCount(): number {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
}
/** Human label for a resolution code. */
resolutionLabel(status: string | null): string {
switch (status) {
case 'RESOLVED_SYSTEM_ERROR':
return 'System entry was wrong';
case 'RESOLVED_SLIP_ERROR':
return 'Slip was written wrong';
case 'RESOLVED_NO_ACTION':
return 'No action needed';
default:
return status ?? '';
}
}
/** Vouchers nobody has checked against a slip yet. */
get uncheckedCount(): number {
return this.rows.filter((r) => !r.verifyStatus).length;
}
toggleExceptionsOnly(): void {
this.exceptionsOnly = !this.exceptionsOnly;
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.exceptionsOnly ? this.exceptionRows : 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. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED';
}
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'APPROVED':
case 'COMPLETED':
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) : '—';
}
// ── Physical slip verification ────────────────────────────────────────────
/** Row whose verification panel is open, or null when none is. */
verifyRow: DayBookRow | null = null;
verifySlipAmount: number | null = null;
verifyRemarks = '';
verifySaving = false;
openVerify(r: DayBookRow): void {
this.verifyRow = r;
// Pre-filled with the system's own figure: the common case is that the two agree, and when
// they don't the cashier only has to change the digits that actually differ.
this.verifySlipAmount = Number(r.amount ?? 0);
this.verifyRemarks = r.verifyRemarks ?? '';
this.attachRow = null;
}
closeVerify(): void {
this.verifyRow = null;
this.verifySlipAmount = null;
this.verifyRemarks = '';
}
/** True when the amount typed off the slip differs from what the system recorded. */
get verifyMismatch(): boolean {
if (!this.verifyRow || this.verifySlipAmount == null) return false;
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
}
/**
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
*/
get verifySlipBlank(): boolean {
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
}
get verifyDifference(): number {
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
}
/**
* Saves the verdict. The status is derived from the figures rather than picked by the user —
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
*/
saveVerify(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
const isException = this.verifyMismatch;
if (isException && !this.verifyRemarks.trim()) {
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
.verifyVoucher(
row.voucherNo,
isException ? 'EXCEPTION' : 'VERIFIED',
isException ? Number(this.verifySlipAmount) : undefined,
this.verifyRemarks.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
/**
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
* is exactly what an investigation needs.
*/
raiseException(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
if (!this.verifyRemarks.trim()) {
this.errorMsg = 'Say what the problem is before raising an exception.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
// both the slip and the system said at the time the exception was raised.
.verifyVoucher(
row.voucherNo,
'EXCEPTION',
Number(this.verifySlipAmount ?? row.amount ?? 0),
this.verifyRemarks.trim()
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
// ── Attachments ───────────────────────────────────────────────────────────
attachRow: DayBookRow | null = null;
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
openAttach(r: DayBookRow): void {
this.attachRow = r;
this.verifyRow = null;
this.attachments = [];
if (!r.voucherNo) return;
this.attachLoading = true;
this.service
.listVoucherAttachments(r.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
closeAttach(): void {
this.attachRow = null;
this.attachments = [];
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.attachRow;
if (!file || !row?.voucherNo) return;
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
// only to have it refused.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo!,
file.name,
file.type || 'application/octet-stream',
String(reader.result)
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.openAttach(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
/**
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
* then handed to the browser as a normal save.
*/
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
deleteAttachment(a: any): void {
const row = this.attachRow;
this.service
.deleteVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (row) this.openAttach(row);
this.load();
});
}
exportCsv(): void {
const header = [
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Slip Check',
'Slip Amount',
'Remarks',
'Checked By',
'Resolution',
'Resolution Notes',
'Closed By',
'Attachments',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
r.verifyStatus ?? 'NOT CHECKED',
r.slipAmount != null ? String(r.slipAmount) : '',
r.verifyRemarks ?? '',
r.verifiedByName ?? '',
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
r.resolutionNotes ?? '',
r.resolvedByName ?? '',
String(r.attachmentCount ?? 0),
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);
}
}