Cashier token-based withdrawal disbursement was re-running the balance/min-balance checks against the already-approved amount, which always failed since the manager approval had already deducted the balance — the check now skips for that case (clerk-created requests still get the full validation). Cheque book request submission now shows the actual backend error message (e.g. insufficient-balance rejections) instead of a generic fallback string. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
367 lines
11 KiB
TypeScript
367 lines
11 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/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
|
import { Router } from '@angular/router';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatTableModule } from '@angular/material/table';
|
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { Subject } from 'rxjs';
|
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
|
|
/** Custom Services */
|
|
import { ChequeBookService } from '../chequebook.service';
|
|
|
|
/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */
|
|
@Component({
|
|
selector: 'mifosx-cheque-books',
|
|
templateUrl: './cheque-books.component.html',
|
|
styleUrls: ['./cheque-books.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatIconModule,
|
|
MatButtonModule,
|
|
MatTableModule,
|
|
MatPaginatorModule,
|
|
MatSnackBarModule,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class ChequeBooksComponent implements OnInit {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private chequeBookService = inject(ChequeBookService);
|
|
private router = inject(Router);
|
|
private snackBar = inject(MatSnackBar);
|
|
|
|
private notify(message: string, isError = false): void {
|
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
|
}
|
|
|
|
books: any[] = [];
|
|
totalRecords = 0;
|
|
pageSize = 10;
|
|
pageIndex = 0;
|
|
pageSizeOptions = [
|
|
10,
|
|
25,
|
|
50,
|
|
100
|
|
];
|
|
seriesList: any[] = [];
|
|
offices: any[] = [];
|
|
stockLots: any[] = [];
|
|
busy = false;
|
|
|
|
showRequestForm = false;
|
|
issueForBookId: number | null = null;
|
|
issueForBook: any = null;
|
|
rejectForBookId: number | null = null;
|
|
statusFilter = '';
|
|
|
|
requestForm: UntypedFormGroup;
|
|
issueForm: UntypedFormGroup;
|
|
rejectForm: UntypedFormGroup;
|
|
|
|
// account-number lookup for the request form
|
|
private accountSearch$ = new Subject<string>();
|
|
resolvedAccount: any = null;
|
|
searchingAccount = false;
|
|
accountError = '';
|
|
|
|
columns = [
|
|
'id',
|
|
'accountType',
|
|
'account',
|
|
'officeName',
|
|
'seriesCode',
|
|
'quantity',
|
|
'status',
|
|
'requestedDate',
|
|
'actions'
|
|
];
|
|
|
|
constructor() {
|
|
this.requestForm = this.formBuilder.group({
|
|
accountType: [
|
|
'SAVINGS',
|
|
Validators.required
|
|
],
|
|
accountNo: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
accountId: [
|
|
null,
|
|
Validators.required
|
|
],
|
|
officeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
seriesId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
noOfBooks: [
|
|
1,
|
|
Validators.required
|
|
]
|
|
});
|
|
this.issueForm = this.formBuilder.group({
|
|
collectionAcknowledged: [false]
|
|
});
|
|
this.rejectForm = this.formBuilder.group({
|
|
rejectionReason: [
|
|
'',
|
|
Validators.required
|
|
]
|
|
});
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.loadBooks();
|
|
this.loadStock();
|
|
// populates a dropdown, so fetch every active series in one page rather than paging through it
|
|
this.chequeBookService
|
|
.getSeries('ACTIVE', 0, 500)
|
|
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
|
|
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
|
|
|
|
// account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module)
|
|
this.accountSearch$
|
|
.pipe(debounceTime(400), distinctUntilChanged())
|
|
.subscribe((accountNo) => this.doAccountSearch(accountNo));
|
|
}
|
|
|
|
/** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */
|
|
onAccountNoInput(value: string): void {
|
|
this.resolvedAccount = null;
|
|
this.accountError = '';
|
|
this.requestForm.patchValue({ accountId: null });
|
|
const trimmed = (value || '').trim();
|
|
if (trimmed.length >= 4) {
|
|
this.accountSearch$.next(trimmed);
|
|
}
|
|
}
|
|
|
|
/** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */
|
|
onAccountTypeChange(): void {
|
|
this.resolvedAccount = null;
|
|
this.accountError = '';
|
|
this.requestForm.patchValue({ accountId: null, seriesId: '' });
|
|
const accountNo = (this.requestForm.get('accountNo')?.value || '').trim();
|
|
if (accountNo.length >= 4) {
|
|
this.accountSearch$.next(accountNo);
|
|
}
|
|
}
|
|
|
|
/** Series applicable to whichever account type is currently selected in the request form — every series is
|
|
* tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */
|
|
get requestableSeries(): any[] {
|
|
const type = this.requestForm.get('accountType')?.value;
|
|
return this.seriesList.filter((s) => s.applicableAccountType === type);
|
|
}
|
|
|
|
private doAccountSearch(accountNo: string): void {
|
|
const accountType = this.requestForm.get('accountType')?.value;
|
|
this.searchingAccount = true;
|
|
this.accountError = '';
|
|
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
|
|
next: (res: any) => {
|
|
this.searchingAccount = false;
|
|
const items = res?.pageItems || (Array.isArray(res) ? res : []);
|
|
const exact = items.find((i: any) => i.accountNo === accountNo);
|
|
const match = exact || (items.length === 1 ? items[0] : null);
|
|
if (match) {
|
|
this.resolvedAccount = {
|
|
id: match.id,
|
|
accountNo: match.accountNo,
|
|
clientName: match.clientName || match.clientDisplayName || match.groupName || '',
|
|
status: match.status?.value || ''
|
|
};
|
|
this.requestForm.patchValue({ accountId: match.id });
|
|
} else {
|
|
this.accountError = 'No account found with that number';
|
|
}
|
|
},
|
|
error: () => {
|
|
this.searchingAccount = false;
|
|
this.accountError = 'Account lookup failed';
|
|
}
|
|
});
|
|
}
|
|
|
|
loadStock(): void {
|
|
// feeds availability calculations below, so fetch every available lot in one page rather than paging through it
|
|
this.chequeBookService
|
|
.getStockLots(undefined, undefined, 'AVAILABLE', 0, 500)
|
|
.subscribe((res: any) => (this.stockLots = res?.pageItems ?? []));
|
|
}
|
|
|
|
/** Whole books currently on hand for a series, optionally narrowed to one office. */
|
|
availableBooksForSeries(seriesId: number, officeId?: number): number {
|
|
return this.stockLots
|
|
.filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE')
|
|
.reduce((sum, l) => sum + (l.remainingBooks || 0), 0);
|
|
}
|
|
|
|
/** Availability at whichever office is currently chosen in the request form. */
|
|
availableBooksForRequestSeries(seriesId: number): number {
|
|
return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value);
|
|
}
|
|
|
|
loadBooks(): void {
|
|
this.chequeBookService
|
|
.getBooks(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
this.statusFilter || undefined,
|
|
this.pageIndex * this.pageSize,
|
|
this.pageSize
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
|
this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length);
|
|
});
|
|
}
|
|
|
|
filterByStatus(status: string): void {
|
|
this.statusFilter = this.statusFilter === status ? '' : status;
|
|
this.pageIndex = 0;
|
|
this.loadBooks();
|
|
}
|
|
|
|
onPageChange(event: PageEvent): void {
|
|
this.pageSize = event.pageSize;
|
|
this.pageIndex = event.pageIndex;
|
|
this.loadBooks();
|
|
}
|
|
|
|
openBook(book: any): void {
|
|
this.router.navigate([
|
|
'/chequebook',
|
|
'books',
|
|
book.id
|
|
]);
|
|
}
|
|
|
|
submitRequest(): void {
|
|
if (this.requestForm.invalid || this.busy) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
const raw = this.requestForm.value;
|
|
const payload = {
|
|
accountType: raw.accountType,
|
|
accountId: raw.accountId,
|
|
officeId: raw.officeId,
|
|
seriesId: raw.seriesId,
|
|
noOfBooks: raw.noOfBooks,
|
|
locale: 'en'
|
|
};
|
|
this.chequeBookService.requestBook(payload).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.showRequestForm = false;
|
|
this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 });
|
|
this.resolvedAccount = null;
|
|
this.accountError = '';
|
|
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
|
this.loadBooks();
|
|
},
|
|
error: (err: any) => {
|
|
this.busy = false;
|
|
const msg =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
'Request failed — check the account is active and the cheque-book facility is enabled';
|
|
this.notify(msg, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleIssue(book: any): void {
|
|
const opening = this.issueForBookId !== book.id;
|
|
this.issueForBookId = opening ? book.id : null;
|
|
this.issueForBook = opening ? book : null;
|
|
this.issueForm.reset({ collectionAcknowledged: false });
|
|
}
|
|
|
|
/** Books available right now for the series this book was already requested against. */
|
|
get issueAvailableBooks(): number {
|
|
return this.issueForBook?.seriesId
|
|
? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId)
|
|
: 0;
|
|
}
|
|
|
|
submitIssue(bookId: number): void {
|
|
if (this.issueForm.invalid || this.busy) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
const raw = this.issueForm.value;
|
|
const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' };
|
|
this.chequeBookService.issueBook(bookId, payload).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.issueForBookId = null;
|
|
this.issueForBook = null;
|
|
this.notify('Cheque book issued');
|
|
this.loadBooks();
|
|
this.loadStock();
|
|
},
|
|
error: () => {
|
|
this.busy = false;
|
|
this.notify('Issue failed — check there is enough available stock for this series at the office', true);
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleReject(book: any): void {
|
|
this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id;
|
|
this.rejectForm.reset();
|
|
}
|
|
|
|
submitReject(bookId: number): void {
|
|
if (this.rejectForm.invalid || this.busy) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' };
|
|
this.chequeBookService.rejectBook(bookId, payload).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.rejectForBookId = null;
|
|
this.notify('Cheque book request rejected');
|
|
this.loadBooks();
|
|
},
|
|
error: () => {
|
|
this.busy = false;
|
|
this.notify('Reject failed', true);
|
|
}
|
|
});
|
|
}
|
|
|
|
withdraw(book: any): void {
|
|
this.chequeBookService.withdrawBook(book.id).subscribe({
|
|
next: () => {
|
|
this.notify('Cheque book withdrawn');
|
|
this.loadBooks();
|
|
},
|
|
error: () => this.notify('Withdraw failed', true)
|
|
});
|
|
}
|
|
}
|