/** * 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; } }