- Cash Transfer now raises a request ("Send for approval") instead of posting. A My Requests button in the
page header opens the caller's requests with their status, and a Cash Transfers approvals page (review
popup with the full denomination breakdown, approve, reject with a reason) lets a different manager release
the cash.
- Transaction Entry's Branch dropdown lists the installation's real offices instead of a fixed list. A login
that cannot read offices is offered only its own branch (read-only), so no "Unauthorized Request" popup.
- Approval Center header and tab bar made compact so more tabs fit on a 100% window.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
191 lines
6.1 KiB
TypeScript
191 lines
6.1 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, TemplateRef, ViewChild, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
import { of } from 'rxjs';
|
|
import { catchError } from 'rxjs/operators';
|
|
import { CashierService } from '../cashier.service';
|
|
|
|
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
|
|
interface CashTransferRequest {
|
|
id: number;
|
|
transfer_date: string | null;
|
|
from_party: string | null;
|
|
to_party: string | null;
|
|
note: string | null;
|
|
total_amount: number;
|
|
maker_name: string | null;
|
|
maker_at: string | null;
|
|
denominations: { denominationValue: number; count: number }[];
|
|
}
|
|
|
|
/**
|
|
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
|
|
* leaves the vault only when it is approved, by someone other than the person who raised it.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-cash-transfer-approvals',
|
|
templateUrl: './cash-transfer-approvals.component.html',
|
|
styleUrls: ['./cash-transfer-approvals.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatDialogModule,
|
|
MatIconModule,
|
|
MatSnackBarModule
|
|
]
|
|
})
|
|
export class CashTransferApprovalsComponent implements OnInit {
|
|
private service = inject(CashierService);
|
|
private snackBar = inject(MatSnackBar);
|
|
private dialog = inject(MatDialog);
|
|
|
|
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
|
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
|
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
|
private reviewRef: MatDialogRef<unknown> | null = null;
|
|
|
|
/** The request open in the review popup, with every note and coin it asks for. */
|
|
reviewing: CashTransferRequest | null = null;
|
|
|
|
all: CashTransferRequest[] = [];
|
|
loading = false;
|
|
loaded = false;
|
|
busyId: number | null = null;
|
|
searchTerm = '';
|
|
|
|
rejecting: CashTransferRequest | null = null;
|
|
rejectReason = '';
|
|
|
|
ngOnInit(): void {
|
|
this.load();
|
|
}
|
|
|
|
load(): void {
|
|
this.loading = true;
|
|
this.service
|
|
.getCashTransferRequests('PENDING')
|
|
.pipe(catchError(() => of([] as CashTransferRequest[])))
|
|
.subscribe((rows) => {
|
|
this.all = rows as CashTransferRequest[];
|
|
this.loading = false;
|
|
this.loaded = true;
|
|
});
|
|
}
|
|
|
|
get rows(): CashTransferRequest[] {
|
|
const term = this.searchTerm.trim().toLowerCase();
|
|
if (!term) return this.all;
|
|
return this.all.filter((r) => [
|
|
r.from_party,
|
|
r.to_party,
|
|
r.maker_name,
|
|
r.note
|
|
].some((v) => (v ?? '').toLowerCase().includes(term)));
|
|
}
|
|
|
|
/** One line per denomination asked for, largest first, with what each line adds up to. */
|
|
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
|
|
return [...(r.denominations ?? [])]
|
|
.filter((d) => d.count > 0)
|
|
.sort((a, b) => b.denominationValue - a.denominationValue)
|
|
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
|
|
}
|
|
|
|
/** How many notes and coins the request asks for in all. */
|
|
piecesOf(r: CashTransferRequest): number {
|
|
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
|
|
}
|
|
|
|
review(r: CashTransferRequest): void {
|
|
this.reviewing = r;
|
|
this.reviewRef?.close();
|
|
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
|
|
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
|
|
}
|
|
|
|
closeReview(): void {
|
|
this.reviewRef?.close();
|
|
}
|
|
|
|
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
|
|
rejectFromReview(r: CashTransferRequest): void {
|
|
this.reviewRef?.close();
|
|
this.startReject(r);
|
|
}
|
|
|
|
formatDateTime(value: string | null): string {
|
|
if (!value) return '—';
|
|
const d = new Date(value);
|
|
return isNaN(d.getTime())
|
|
? value
|
|
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
approve(r: CashTransferRequest): void {
|
|
if (this.busyId) return;
|
|
this.busyId = r.id;
|
|
this.service.approveCashTransferRequest(r.id).subscribe({
|
|
next: (res: any) => {
|
|
this.busyId = null;
|
|
this.reviewRef?.close();
|
|
this.snackBar.open(
|
|
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
|
|
'Close',
|
|
{ duration: 6000 }
|
|
);
|
|
this.load();
|
|
},
|
|
error: (err) => {
|
|
this.busyId = null;
|
|
this.fail(err, 'Could not approve');
|
|
}
|
|
});
|
|
}
|
|
|
|
startReject(r: CashTransferRequest): void {
|
|
this.rejecting = r;
|
|
this.rejectReason = '';
|
|
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
|
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
|
}
|
|
|
|
cancelReject(): void {
|
|
this.dialogRef?.close();
|
|
}
|
|
|
|
confirmReject(): void {
|
|
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
|
const id = this.rejecting.id;
|
|
this.busyId = id;
|
|
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
|
|
next: () => {
|
|
this.busyId = null;
|
|
this.dialogRef?.close();
|
|
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
|
this.load();
|
|
},
|
|
error: (err) => {
|
|
this.busyId = null;
|
|
this.fail(err, 'Could not reject');
|
|
}
|
|
});
|
|
}
|
|
|
|
private fail(err: any, fallback: string): void {
|
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
|
}
|
|
}
|