feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user