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,169 @@
<!--
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 ar-print">
<header class="pg-head">
<div>
<div class="pg-eyebrow">REPORTS · ACCOUNT REGISTER</div>
<h1 class="pg-title">Account Register</h1>
<p class="pg-sub">
Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held
then, and what it holds now.
</p>
</div>
<div class="pg-toolbar ar-noprint">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>As on 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>
<!-- Shown only on paper, where the toolbar is hidden and the date has to be stated. -->
<p class="ar-printhead">Account Register as on {{ date }} · {{ section }}</p>
<div class="db-filters ar-noprint">
<label class="pg-field">
<span>Section</span>
<select [(ngModel)]="section" (change)="load()">
@for (s of sections; track s.value) {
<option [value]="s.value">{{ s.label }}</option>
}
</select>
</label>
<label class="pg-field ar-check">
<span>Movement</span>
<span class="ar-check__box">
<input type="checkbox" [(ngModel)]="movementOnly" />
Only accounts opened / closed on this date
</span>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Account no, client, product, status…" />
</label>
<button type="button" class="pg-btn" (click)="printRegister()" [disabled]="filteredRows.length === 0">
<mat-icon>print</mat-icon> Print
</button>
<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>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded) {
<!-- What actually happened that day, read before the list itself. -->
<div class="ar-summary">
<div class="ar-summary__item ar-summary__item--in">
<span class="ar-summary__label">Accounts opened on {{ date }}</span>
<span class="ar-summary__value">{{ openedCount }}</span>
</div>
<div class="ar-summary__item ar-summary__item--out">
<span class="ar-summary__label">Accounts closed on {{ date }}</span>
<span class="ar-summary__value">{{ closedCount }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Balance on {{ date }}</span>
<span class="ar-summary__value">₹{{ shownBalanceOnDate | number: '1.2-2' }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Current balance</span>
<span class="ar-summary__value">₹{{ shownCurrentBalance | number: '1.2-2' }}</span>
</div>
</div>
}
@if (!loading && loaded && filteredRows.length === 0) {
<div class="pg-empty">
<mat-icon>account_balance_wallet</mat-icon>
<p>
@if (movementOnly) {
No account was opened or closed on {{ date }}.
} @else {
No accounts existed on {{ date }}.
}
</p>
</div>
}
@if (filteredRows.length > 0) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Section</th>
<th>Client</th>
<th>Product</th>
<th>Status</th>
<th>Opened</th>
<th>Closed</th>
<th class="num">Balance on {{ date }}</th>
<th class="num">Current Balance</th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.section + r.accountId) {
<tr [class.ar-row--opened]="r.openedOnDate" [class.ar-row--closed]="r.closedOnDate">
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
@if (r.openedOnDate) {
<span class="ar-tag ar-tag--in">OPENED</span>
}
@if (r.closedOnDate) {
<span class="ar-tag ar-tag--out">CLOSED</span>
}
</td>
<td>{{ r.section }}</td>
<td>{{ r.clientName ?? '—' }}</td>
<td>{{ r.productName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.openedOn ?? '—' }}</td>
<td>{{ r.closedOn ?? '—' }}</td>
<td class="num">₹{{ r.balanceOnDate | number: '1.2-2' }}</td>
<td class="num">
₹{{ r.currentBalance | number: '1.2-2' }}
@if (r.section === 'SHARE' && r.sharesHeld) {
<span class="db-muted">· {{ r.sharesHeld }} sh</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">
{{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
</td>
<td class="num">₹{{ shownBalanceOnDate | number: '1.2-2' }}</td>
<td class="num">₹{{ shownCurrentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,144 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the
day book. Only what belongs to the account register lives here. */
.ar-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__item {
flex: 1 1 180px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&--in {
border-left: 4px solid #16a34a;
}
&--out {
border-left: 4px solid #dc2626;
}
}
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 4px;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
}
/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on
one line without fighting that. */
.ar-check {
&__box {
display: flex;
align-items: center;
gap: 6px;
height: 38px;
font-size: 12px;
color: #475569;
white-space: nowrap;
}
}
/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register
for a past day at all. */
.ar-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
&--in {
background: #dcfce7;
color: #15803d;
}
&--out {
background: #fee2e2;
color: #b91c1c;
}
}
.ar-row--opened td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.ar-row--closed td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
/* Only shown on paper: the screen already states the date in the toolbar. */
.ar-printhead {
display: none;
}
/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and
filters go, the table gains borders, and rows are not split across pages. */
@media print {
.ar-noprint {
display: none !important;
}
.ar-printhead {
display: block;
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
color: #000;
}
.pg {
padding: 0;
}
.ar-summary__item {
border: 1px solid #999;
}
.pg-table {
font-size: 10px;
th,
td {
border: 1px solid #999;
padding: 4px 6px;
color: #000;
}
}
tr {
page-break-inside: avoid;
}
/* Table headers repeat on every page, so a multi-page register stays readable. */
thead {
display: table-header-group;
}
}
@@ -0,0 +1,221 @@
/**
* 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 RegisterRow {
accountId: number;
accountNo: string;
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
clientName: string | null;
productName: string | null;
status: string;
openedOn: string | null;
closedOn: string | null;
/** True when this account was opened (or closed) on the very date being viewed. */
openedOnDate: boolean;
closedOnDate: boolean;
/** The account's balance as it stood on the register's date, for every section. */
balanceOnDate: number | null;
currentBalance: number | null;
sharesHeld?: number;
}
/**
* Account register for one date: which accounts opened and closed that day, each account's balance
* as it stood then, and what it holds now.
*/
@Component({
selector: 'mifosx-account-register',
templateUrl: './account-register.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./account-register.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountRegisterComponent implements OnInit {
private service = inject(CashierService);
date = '';
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
search = '';
/** Narrows to just the accounts that opened or closed on the chosen date. */
movementOnly = false;
rows: RegisterRow[] = [];
openedCount = 0;
closedCount = 0;
totalBalanceOnDate = 0;
totalCurrentBalance = 0;
loading = false;
loaded = false;
errorMsg = '';
readonly sections = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'FD', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'SHARE', label: 'Share' }
];
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date — 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
.accountRegister(this.date, this.section)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not load the account register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.accounts ?? [];
this.openedCount = Number(res?.openedCount ?? 0);
this.closedCount = Number(res?.closedCount ?? 0);
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
});
}
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();
}
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
get filteredRows(): RegisterRow[] {
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.accountNo,
r.clientName,
r.productName,
r.status,
r.section
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a filter or search still reconciles. */
get shownBalanceOnDate(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
}
get shownCurrentBalance(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'db-badge db-badge--ok';
case 'CLOSED':
case 'PREMATURELY CLOSED':
case 'REJECTED':
case 'WITHDRAWN':
return 'db-badge db-badge--rejected';
case 'SUBMITTED':
case 'APPROVED':
return 'db-badge db-badge--pending';
default:
return 'db-badge';
}
}
exportCsv(): void {
const header = [
'Account No',
'Section',
'Client',
'Product',
'Status',
'Opened On',
'Closed On',
`Balance on ${this.date}`,
'Current Balance'
];
const lines = this.filteredRows.map((r) =>
[
r.accountNo,
r.section,
r.clientName ?? '',
r.productName ?? '',
r.status,
r.openedOn ?? '',
r.closedOn ?? '',
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
String(r.currentBalance ?? 0)
]
// Quote every field and double embedded quotes: client names routinely contain commas.
.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 = `account-register-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
printRegister(): void {
window.print();
}
}
@@ -0,0 +1,337 @@
<!--
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">BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}</div>
<h1 class="pg-title">{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}</h1>
<p class="pg-sub">
@if (missedOnly) {
Business days that were opened and never closed. While a day stays open its closing balance never carries
forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed.
} @else {
The branch's business day, opened each morning and closed each evening. A day left open never carries its
closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are
listed first.
}
</p>
</div>
<div class="pg-toolbar">
@if (canOpenToday && !missedOnly) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startOpen()">
<mat-icon>lock_open</mat-icon> Open Day
</button>
}
<button type="button" class="pg-btn" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="bd-success">{{ successMsg }}</div>
}
@if (!canOperate && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a
cashier works inside the day and cannot declare it closed.
</div>
}
<!-- Missed closes first: these are the ones that need action. -->
@if (missed.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>{{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed</strong>
</div>
@for (m of missed; track m.id) {
<div class="bd-missed">
<span class="pg-chip">{{ m.businessDate }}</span>
<strong class="bd-missed__age"> open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} </strong>
<span class="db-muted">
opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
</span>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--warn bd-missed__act" (click)="startClose(m)">
Close this day
</button>
}
</div>
}
</div>
}
<!-- On the missed-close screen, nothing to report is itself the answer — and has to be stated,
or the page looks like it failed to load. -->
@if (missedOnly && loaded && missed.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>No missed closes. Every business day opened for this branch has been closed.</p>
</div>
}
<!-- Current day -->
@if (!missedOnly && openDay) {
<div class="bd-current">
<div class="bd-current__main">
<span class="db-badge db-badge--pending">DAY OPEN</span>
<strong class="bd-current__date">{{ openDay.businessDate }}</strong>
<span class="db-muted">
opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
openDay.openingCash | number: '1.2-2'
}}
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
<span class="db-badge">NO DAY OPEN</span>
<span class="db-muted">Open the business day before any cash transaction is accepted.</span>
</div>
</div>
}
<!-- Open-day form -->
@if (showOpenForm) {
<div class="db-panel bd-panel">
<div class="db-panel__head">
<strong>Open business day</strong>
<button type="button" class="db-icon-btn" (click)="cancelOpen()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<label class="pg-field">
<span>Business date</span>
<input type="date" [(ngModel)]="openDate" />
</label>
<label class="pg-field">
<span>Opening cash (leave blank to carry forward)</span>
<input type="number" step="0.01" [(ngModel)]="openingCash" placeholder="Carried from last close" />
</label>
</div>
<p class="bd-hint">
Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance
from drifting.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
Open Day
</button>
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
</div>
</div>
}
<!-- Close-day form -->
@if (closeTarget) {
<div class="db-panel bd-panel" [class.bd-panel--late]="isLateClose">
<div class="db-panel__head">
<strong>Close business day — {{ closeTarget.businessDate }}</strong>
<button type="button" class="db-icon-btn" (click)="cancelClose()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
@if (isLateClose) {
<p class="db-panel__warn">
This day is being closed after its own date. It will be recorded as a
<strong>force close</strong>, with the reason below kept permanently against it.
</p>
}
@if (previewLoading) {
<p class="db-muted">Working out the day's figure…</p>
}
<!-- The arithmetic, spelled out. Whoever closes the day can see where the expected figure
comes from instead of being asked to agree with a number they cannot check. -->
@if (preview) {
<div class="bd-calc">
<div class="bd-calc__row">
<span>Opening cash (carried forward)</span>
<span class="bd-calc__amt">₹{{ preview.openingCash | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash deposits taken (approved)</span>
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash drawn from vault</span>
<span class="bd-calc__amt">₹{{ preview.vaultIn | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash paid out (approved withdrawals)</span>
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash settled back to vault</span>
<span class="bd-calc__amt">₹{{ preview.settledOut | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--total">
<span>Cash the branch should be holding</span>
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
</div>
</div>
}
<div class="db-panel__body">
<label class="pg-field">
<span>Physical cash counted</span>
<input type="number" step="0.01" [(ngModel)]="closingCash" placeholder="0.00" />
</label>
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">
{{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
}}
</span>
</div>
}
}
@if (preview && liveDifference !== 0) {
<button type="button" class="pg-btn" (click)="useExpected()">Use expected figure</button>
}
</div>
<p class="bd-hint">
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day.
</p>
@if (isLateClose) {
<label class="pg-field bd-panel__wide">
<span>Why was this day left open? (required)</span>
<input
type="text"
[(ngModel)]="forceReason"
placeholder="e.g. cashier on leave, cash not settled that evening"
/>
</label>
}
<label class="pg-field bd-panel__wide">
<span>Remarks (optional)</span>
<input type="text" [(ngModel)]="closeRemarks" placeholder="Anything worth recording about this day" />
</label>
<p class="bd-hint">
The figure above is stored against the record, so a difference stays readable later even if a transaction is
reversed afterwards.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ isLateClose ? 'Force Close Day' : 'Close Day' }}
</button>
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && days.length === 0 && !missedOnly) {
<div class="pg-empty">
<mat-icon>event_note</mat-icon>
<p>No business day has been opened for this branch yet.</p>
</div>
}
<!-- The register -->
@if (days.length > 0 && !missedOnly) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Business Date</th>
<th>Status</th>
<th>Opened By</th>
<th class="num">Opening ₹</th>
<th>Closed By</th>
<th class="num">Closing ₹</th>
<th class="num">Book ₹</th>
<th>Difference</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (d of days; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.businessDate }}</span>
</td>
<td>
@if (d.status === 'OPEN') {
<span class="db-badge db-badge--pending">OPEN</span>
} @else if (d.forceClosed) {
<span class="db-badge db-badge--rejected" [title]="d.forceCloseReason ?? ''">FORCE CLOSED</span>
} @else {
<span class="db-badge db-badge--ok">CLOSED</span>
}
</td>
<td>
{{ d.openedByName ?? '—' }}
<span class="db-muted">· {{ timeOnly(d.openedAt) }}</span>
</td>
<td class="num">₹{{ d.openingCash | number: '1.2-2' }}</td>
<td>
{{ d.closedByName ?? '—' }}
@if (d.closedAt) {
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</td>
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (d.cashDifference == null) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">{{ differenceLabel(d.cashDifference) }}</span>
}
</td>
<td class="bd-note">
@if (d.forceCloseReason) {
<strong>Left open:</strong> {{ d.forceCloseReason }}
}
@if (d.remarks) {
<span class="db-muted">{{ d.remarks }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,172 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
with the day book. Only what is specific to the day register lives here. */
/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
this component does not depend on another component's stylesheet. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
}
/* One row per never-closed day, inside the shared red exception panel. */
.bd-missed {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 7px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
&__age {
color: #b91c1c;
}
/* Pushed right so the date and age read before the action. */
&__act {
margin-left: auto;
height: 28px;
font-size: 11px;
}
}
/* The branch's current day — the one thing someone opening this screen is looking for. */
.bd-current {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 8px;
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
&--shut {
border-left-color: #94a3b8;
background: #f8fafc;
}
&__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__date {
font-size: 15px;
color: #0d1b2a;
}
.pg-btn {
margin-left: auto;
}
}
.bd-panel {
margin-bottom: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* A late close is a different act from an ordinary one, and looks like it. */
&--late {
border-left: 4px solid #dc2626;
}
&__wide {
display: block;
margin-top: 12px;
max-width: 640px;
}
}
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
the screen it came from. */
.bd-calc {
max-width: 520px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
color: #475569;
&--in .bd-calc__amt {
color: #15803d;
}
&--out .bd-calc__amt {
color: #b91c1c;
}
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 2px solid #e2e8f0;
font-weight: 800;
font-size: 14px;
color: #0d1b2a;
}
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
}
.bd-hint {
margin: 10px 0 0;
font-size: 11px;
color: #94a3b8;
}
.bd-note {
max-width: 280px;
font-size: 11px;
line-height: 1.4;
}
@@ -0,0 +1,284 @@
/**
* 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 { ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface BranchDay {
id: number;
officeId: number;
officeName: string | null;
businessDate: string;
openedByName: string | null;
openedAt: string | null;
openingCash: number | null;
closedByName: string | null;
closedAt: string | null;
closingCash: number | null;
bookCash: number | null;
cashDifference: number | null;
forceClosed: boolean;
forceCloseReason: string | null;
remarks: string | null;
status: 'OPEN' | 'CLOSED';
/** Only present on the missed-close list. */
daysOpen?: number;
}
/**
* Branch day register — opening and closing the branch's business day, and the list of days that
* were opened and never closed.
*
* A day left open is not a tidiness problem: its closing balance never carries forward, a
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
*/
@Component({
selector: 'mifosx-branch-day',
templateUrl: './branch-day.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./branch-day.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class BranchDayComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/**
* Set from the route: the missed-close register is the same data as the day register, shown with
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
* Close Register" looked like nothing had happened whenever the branch was up to date.
*/
missedOnly = false;
days: BranchDay[] = [];
missed: BranchDay[] = [];
openDay: any = null;
today = '';
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
canOperate = false;
loading = false;
loaded = false;
errorMsg = '';
successMsg = '';
// Open-day form
showOpenForm = false;
openDate = '';
openingCash: number | null = null;
opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed.
closeTarget: BranchDay | null = null;
closingCash: number | null = null;
closeRemarks = '';
forceReason = '';
closing = false;
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.branchDayRegister()
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.days = res?.days ?? [];
this.openDay = res?.openDay ?? null;
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
});
this.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay;
}
startOpen(): void {
this.showOpenForm = true;
this.openDate = this.today;
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.successMsg = '';
this.errorMsg = '';
}
cancelOpen(): void {
this.showOpenForm = false;
}
submitOpen(): void {
if (!this.openDate) return;
this.opening = true;
this.errorMsg = '';
this.service
.openBranchDay(this.openDate, this.openingCash ?? undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.opening = false;
if (res) {
this.showOpenForm = false;
this.successMsg = `Business day ${res.businessDate} opened.`;
this.load();
}
});
}
/** The day's expected figure and the working behind it, fetched when the close form opens. */
preview: any = null;
previewLoading = false;
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.preview = null;
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
// closed on what was physically counted — but in the ordinary case where the drawer tallies
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
this.previewLoading = true;
this.service
.branchDayClosePreview(day.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.previewLoading = false;
this.preview = res;
if (res?.expectedCash != null && this.closingCash == null) {
this.closingCash = Number(res.expectedCash);
}
});
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.closingCash == null) return null;
return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
useExpected(): void {
if (this.preview?.expectedCash != null) {
this.closingCash = Number(this.preview.expectedCash);
}
}
cancelClose(): void {
this.closeTarget = null;
}
/** A date earlier than today is a late close, and has to say why it was left open. */
get isLateClose(): boolean {
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
}
get canSubmitClose(): boolean {
if (!this.closeTarget || this.closingCash == null || this.closing) return false;
return !this.isLateClose || !!this.forceReason.trim();
}
submitClose(): void {
const day = this.closeTarget;
if (!day || this.closingCash == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(this.closingCash),
this.closeRemarks.trim() || undefined,
this.isLateClose ? this.forceReason.trim() : undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.closing = false;
if (res) {
this.closeTarget = null;
const diff = Number(res.cashDifference ?? 0);
this.successMsg =
diff === 0
? `Business day ${res.businessDate} closed — cash tallied.`
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
this.load();
}
});
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';
const n = Number(d);
if (n === 0) return 'Tallied';
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
}
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
timeOnly(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
}
+41
View File
@@ -19,6 +19,10 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@@ -128,6 +132,33 @@ const routes: Routes = [
}
]
},
{
// Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
// day governs the whole branch, and is not something a cashier does.
path: 'branch-ops',
data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
children: [
{
path: 'day-register',
component: BranchDayComponent,
data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
},
{
// Same screen, narrowed to the days that were never closed. A separate route rather than
// a second link to the register, so choosing it visibly does something.
path: 'missed-close',
component: BranchDayComponent,
data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
},
{
// Lives here alongside the day register because both answer "what did this branch look
// like on that date"; it is linked from the Reports menu, where people go to find it.
path: 'account-register',
component: AccountRegisterComponent,
data: { title: 'Account Register', breadcrumb: 'Account Register' }
}
]
},
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -172,6 +203,16 @@ const routes: Routes = [
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
{
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
{
path: 'voucher-exceptions',
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
+207
View File
@@ -113,6 +113,146 @@ export class CashierService {
const params = new HttpParams().set('date', date);
return this.http.get('/cashreports/closing', { params });
}
/**
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
* bank transfers in a single list, with the day's credit and debit totals.
*/
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (side) params = params.set('side', side);
if (status) params = params.set('status', status);
return this.http.get('/cashreports/day-book', { params });
}
// ── Voucher physical verification & attachments ──
// The voucher number always travels in the body or a query parameter, never as a path segment:
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
// encoded slashes with a 400 before the request reaches the resource.
/** Records the outcome of checking a voucher against the paper slip. */
verifyVoucher(
voucherNo: string,
status: 'VERIFIED' | 'EXCEPTION',
slipAmount?: number,
remarks?: string
): Observable<any> {
return this.http.post('/vouchers/verify', {
voucherNo,
status,
slipAmount: slipAmount != null ? String(slipAmount) : null,
remarks: remarks ?? null,
locale: 'en'
});
}
uploadVoucherAttachment(
voucherNo: string,
fileName: string,
contentType: string,
fileData: string,
description?: string
): Observable<any> {
return this.http.post('/vouchers/attachments', {
voucherNo,
fileName,
contentType,
fileData,
description: description ?? null
});
}
listVoucherAttachments(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/attachments', { params });
}
deleteVoucherAttachment(id: number): Observable<any> {
return this.http.delete(`/vouchers/attachments/${id}`);
}
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
downloadVoucherAttachment(id: number): Observable<Blob> {
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
}
/**
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
* raised on Friday still needs answering on Monday.
*/
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
const params = new HttpParams().set('status', status);
return this.http.get('/vouchers/exceptions', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
*/
accountRegister(date: string, section?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (section && section !== 'ALL') params = params.set('section', section);
return this.http.get('/accountregister', { params });
}
// ── Branch day register ──
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (limit) params = params.set('limit', String(limit));
return this.http.get('/branchday', { params });
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/missed', { params });
}
/**
* What the day should close at, and the arithmetic behind it, so the closing figure does not
* have to be worked out by hand.
*/
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (businessDate) params = params.set('businessDate', businessDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/close-preview', { params });
}
openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable<any> {
return this.http.post('/branchday/open', {
businessDate,
openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null,
locale: 'en'
});
}
/** forceCloseReason is required when closing a date earlier than today. */
closeBranchDay(
businessDate: string,
closingCash: number,
remarks?: string,
forceCloseReason?: string,
officeId?: number
): Observable<any> {
return this.http.post('/branchday/close', {
businessDate,
closingCash: String(closingCash),
remarks: remarks ?? null,
forceCloseReason: forceCloseReason ?? null,
officeId: officeId ?? null,
locale: 'en'
});
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -188,4 +328,71 @@ export class CashierService {
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
// (%2F) outright with a 400 before the request ever reaches the resource.
// clientId lets the backend adopt a cashier deposit made straight to the Share Sundry GL,
// which carries no member of its own.
let params = new HttpParams().set('voucherNo', voucherNo);
if (clientId) {
params = params.set('clientId', String(clientId));
}
return this.http.get('/shareadvancecollection/by-voucher', { params });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
applyShareAdvance(collectionId: number, data: any): Observable<any> {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/**
* Whether this share account can be settled from the member's linked savings, and for how much.
*
* Reports a shortfall before the clerk raises anything, so the member can be told at the counter
* rather than after a request has been submitted and has to be rejected.
* @returns {Observable<any>} `{ eligible, reason, shareAmount, chargeAmount, totalRequired,
* savingsAccountNo, savingsBalance }`.
*/
getShareSavingsDebitEligibility(shareAccountId: number): Observable<any> {
return this.http.get(`/sharesavingsdebit/eligibility/${shareAccountId}`);
}
/**
* Records a request to settle a share application by debiting the member's savings. Nothing moves
* until a manager approves it — unlike the cash route, where the cashier's receipt is already the
* second pair of eyes.
*/
submitShareSavingsDebit(data: any): Observable<any> {
return this.http.post('/sharesavingsdebit', data);
}
/** Savings-debit requests, PENDING by default. */
getShareSavingsDebitRequests(status?: string, shareAccountId?: number): Observable<any> {
const params: string[] = [];
if (status) {
params.push(`status=${status}`);
}
if (shareAccountId) {
params.push(`shareAccountId=${shareAccountId}`);
}
return this.http.get(`/sharesavingsdebit${params.length ? '?' + params.join('&') : ''}`);
}
/** Debits the member's savings and issues the shares. Manager only, and not the submitting clerk. */
approveShareSavingsDebit(id: number): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/approve`, {});
}
rejectShareSavingsDebit(id: number, rejectionReason?: string): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/reject`, { rejectionReason });
}
}
@@ -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);
}
}
@@ -2317,8 +2317,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
if (this.kycDetails?.amlCheckStatus === 'FLAGGED')
alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' });
// No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
// prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
// risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
return alerts;
}
@@ -3220,21 +3221,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const AML_GL = 200000;
if (amount > AML_GL && isCash) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
);
return;
}
// No AML prompt on the GL path either — see the note on the savings/current path below.
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
@@ -3314,23 +3301,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
// 6. AML large cash
const AML_THRESHOLD = 200000;
if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPt)
);
return;
}
// AML monitoring is deliberately invisible here. Every posted transaction is evaluated
// server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
// anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
// The cashier is never prompted or warned at the counter: tipping off the customer defeats
// the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
// duplicated none of the real rule set anyway.
this.openConfirmDialog(row, amount, isDeposit, selectedPt);
}
@@ -0,0 +1,183 @@
<!--
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 · EXCEPTIONS</div>
<h1 class="pg-title">Voucher Exceptions</h1>
<p class="pg-sub">
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
document.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Show</span>
<select [(ngModel)]="filter" (change)="load()">
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
<option value="ALL">All</option>
</select>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (!canResolve && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
that whoever raised the flag is not the one who rules on it.
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && exceptions.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
</div>
}
@for (e of exceptions; track e.voucherNo) {
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
<div class="ve-card__head">
<span class="pg-chip">{{ e.voucherNo }}</span>
@if (e.resolutionStatus) {
<span class="db-badge db-badge--ok">RESOLVED</span>
} @else {
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
}
<span class="db-muted">
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
</span>
@if (!e.resolutionStatus && canResolve) {
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
Review &amp; Close
</button>
}
</div>
<div class="ve-card__amts">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
</div>
@if (!amountsAgree(e)) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Written on slip</span>
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
</div>
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp">
<span class="db-cmp__label">Amounts</span>
<span class="db-cmp__value">Agree — flagged for another reason</span>
</div>
}
</div>
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
@if (e.resolutionStatus) {
<div class="ve-card__resolution">
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
</div>
}
<!-- Resolution panel, opened under the exception it closes. -->
@if (resolveRow === e) {
<div class="db-panel ve-panel">
<div class="db-panel__head">
<strong>Close exception — {{ e.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="pg-field ve-panel__field">
<span>What was wrong?</span>
<select [(ngModel)]="resolutionStatus">
<option value="">— Select —</option>
@for (o of resolutionOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="pg-field ve-panel__field">
<span>What was found, and what was done about it?</span>
<input
type="text"
[(ngModel)]="resolutionNotes"
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
/>
</label>
<div class="ve-panel__docs">
<strong>Supporting document</strong>
<p class="db-muted">
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
whatever the decision rests on.
</p>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">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="ve-panel__warn">No document attached yet — required before closing.</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.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveResolution()"
[disabled]="!canSubmitResolution"
>
Close Exception
</button>
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
</div>
</div>
}
</div>
}
</div>
@@ -0,0 +1,120 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
shared with the day book. Only the exception card itself is defined here. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
reads at a glance without opening anything. */
.ve-card {
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--resolved {
border-left-color: #16a34a;
background: #fafffb;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Pushed right so the identity of the voucher reads first. */
&__act {
margin-left: auto;
height: 32px;
font-size: 12px;
}
&__amts {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
&__why {
margin: 0;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #0d1b2a;
}
&__resolution {
margin-top: 10px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #14532d;
}
}
.ve-panel {
margin-top: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__field {
display: block;
margin-bottom: 12px;
max-width: 640px;
}
&__docs {
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
p {
margin: 4px 0 10px;
}
}
&__warn {
padding: 6px 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
color: #92400e;
}
}
@@ -0,0 +1,248 @@
/**
* 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 VoucherException {
voucherNo: string;
systemAmount: number | null;
slipAmount: number | null;
remarks: string | null;
raisedByName: string | null;
raisedAt: string | null;
accountNo: string | null;
party: string | null;
raisedAgainst: string | null;
attachmentCount: number;
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
}
/**
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
*
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
* or manager sits down with them, records what was actually wrong, and attaches the paper that
* supports the decision.
*/
@Component({
selector: 'mifosx-voucher-exceptions',
templateUrl: './voucher-exceptions.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./voucher-exceptions.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherExceptionsComponent implements OnInit {
private service = inject(CashierService);
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
exceptions: VoucherException[] = [];
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
canResolve = false;
loading = false;
loaded = false;
errorMsg = '';
// Resolution panel
resolveRow: VoucherException | null = null;
resolutionStatus = '';
resolutionNotes = '';
resolveSaving = false;
// Attachments for the voucher being resolved
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
readonly resolutionOptions = [
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.voucherExceptions(this.filter)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.exceptions = res?.exceptions ?? [];
this.canResolve = !!res?.canResolve;
});
}
difference(e: VoucherException): number {
if (e.slipAmount == null) return 0;
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
}
/** True when the flag was about something other than the amount. */
amountsAgree(e: VoucherException): boolean {
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
}
openResolve(e: VoucherException): void {
this.resolveRow = e;
this.resolutionStatus = '';
this.resolutionNotes = '';
this.loadAttachments(e);
}
closeResolve(): void {
this.resolveRow = null;
this.attachments = [];
}
private loadAttachments(e: VoucherException): void {
this.attachments = [];
this.attachLoading = true;
this.service
.listVoucherAttachments(e.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.resolveRow;
if (!file || !row) return;
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
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),
'Supporting document for exception resolution'
)
.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.loadAttachments(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
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);
});
}
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
get canSubmitResolution(): boolean {
return (
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
);
}
saveResolution(): void {
const row = this.resolveRow;
if (!row) return;
this.resolveSaving = true;
this.errorMsg = '';
this.service
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.resolveSaving = false;
if (res) {
this.closeResolve();
this.load();
}
});
}
resolutionLabel(status: string | null): string {
if (!status) return '';
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
}
/** Date and time — an exception queue spans days, so the day matters here. */
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
}