249 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|