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