Files
cbsnexora/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts
T

249 lines
7.2 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { 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;
}
}