feat(chequebook): add cheque book module with Maker-Checker workflow
Adds cheque series creation, stock receive, cheque leaf request/lookup, and stop-payment/lost-reporting flows with Maker-Checker approval and a checker-inbox tab for pending chequebook requests.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Cheque Books' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="primary" (click)="showRequestForm = !showRequestForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Request Cheque Book' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of ['REQUESTED', 'ISSUED', 'REJECTED', 'WITHDRAWN']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showRequestForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="requestForm" (ngSubmit)="submitRequest()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="accountType" required (selectionChange)="onAccountTypeChange()">
|
||||
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
|
||||
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Account Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="accountNo"
|
||||
(input)="onAccountNoInput($any($event.target).value)"
|
||||
required
|
||||
/>
|
||||
@if (searchingAccount) {
|
||||
<mat-hint>{{ 'labels.text.Searching...' | translate }}</mat-hint>
|
||||
} @else if (resolvedAccount) {
|
||||
<mat-hint class="ok-hint">
|
||||
✓ {{ resolvedAccount.accountNo }}
|
||||
@if (resolvedAccount.clientName) {
|
||||
— {{ resolvedAccount.clientName }}
|
||||
}
|
||||
@if (resolvedAccount.status) {
|
||||
({{ resolvedAccount.status }})
|
||||
}
|
||||
</mat-hint>
|
||||
} @else if (accountError) {
|
||||
<mat-hint class="err-hint">{{ accountError | translate }}</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>{{ 'labels.text.Type at least 4 digits of the account number' | translate }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||
<mat-select formControlName="officeId" required>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||
<mat-select formControlName="seriesId" required>
|
||||
@for (s of requestableSeries; track s.id) {
|
||||
<mat-option [value]="s.id">
|
||||
{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }},
|
||||
{{ availableBooksForRequestSeries(s.id) }} {{ 'labels.inputs.books available' | translate }})
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>{{ 'labels.text.Only series applicable to this account type are shown' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="noOfBooks" required />
|
||||
<mat-hint>{{ 'labels.text.Leaves handed over = books x the series book size' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showRequestForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="requestForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="books">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="accountType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.accountType }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
{{ b.accountNo || b.accountId }}
|
||||
@if (b.clientName) {
|
||||
<span>({{ b.clientName }})</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.officeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="seriesCode">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.seriesCode }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Quantity' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">
|
||||
{{ b.noOfBooks }} {{ 'labels.inputs.books' | translate }}
|
||||
@if (b.totalLeaves) {
|
||||
<span class="muted">({{ b.totalLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
<span
|
||||
class="pill"
|
||||
[ngClass]="
|
||||
b.status === 'ISSUED' ? 'ok' : b.status === 'REJECTED' || b.status === 'WITHDRAWN' ? 'bad' : 'neutral'
|
||||
"
|
||||
>{{ b.status }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="requestedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Requested Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.requestedDate | dateFormat }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
|
||||
@if (b.status === 'REQUESTED') {
|
||||
<button mat-button color="primary" (click)="toggleIssue(b)">
|
||||
{{ 'labels.buttons.Issue' | translate }}
|
||||
</button>
|
||||
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
|
||||
}
|
||||
@if (b.status === 'ISSUED') {
|
||||
<button mat-button (click)="openBook(b)">{{ 'labels.buttons.View' | translate }}</button>
|
||||
<button mat-button color="warn" (click)="withdraw(b)">{{ 'labels.buttons.Withdraw' | translate }}</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns" (click)="openBook(row)" class="clickable-row"></tr>
|
||||
</table>
|
||||
@if (books.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (issueForBookId) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<h3>{{ 'labels.inputs.Issue Cheque Book' | translate }}</h3>
|
||||
<p class="issue-need">
|
||||
{{ 'labels.inputs.Requested' | translate }}: <strong>{{ issueForBook?.noOfBooks }}</strong>
|
||||
{{ 'labels.inputs.books' | translate }} — {{ issueForBook?.seriesCode }}
|
||||
<span [class.err-hint]="issueAvailableBooks < (issueForBook?.noOfBooks || 0)">
|
||||
({{ issueAvailableBooks }} {{ 'labels.inputs.books available' | translate }})
|
||||
</span>
|
||||
</p>
|
||||
<form [formGroup]="issueForm" (ngSubmit)="submitIssue(issueForBookId)">
|
||||
<mat-checkbox formControlName="collectionAcknowledged">
|
||||
{{ 'labels.inputs.Customer collected the book now' | translate }}
|
||||
</mat-checkbox>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="issueForBookId = null; issueForBook = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="issueForm.invalid || busy">
|
||||
{{ 'labels.buttons.Issue' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (rejectForBookId) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<h3>{{ 'labels.inputs.Reject Cheque Book Request' | translate }}</h3>
|
||||
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForBookId)">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="rejectionReason" required />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="rejectForBookId = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
|
||||
{{ 'labels.buttons.Reject' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button.active {
|
||||
background: rgb(25 118 210 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 3%);
|
||||
}
|
||||
}
|
||||
|
||||
td.num,
|
||||
th.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.issue-need {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.9rem;
|
||||
color: rgb(0 0 0 / 70%);
|
||||
}
|
||||
|
||||
::ng-deep .ok-hint {
|
||||
color: #28c07a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::ng-deep .err-hint {
|
||||
color: #d32f2f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
&.ok {
|
||||
background: #dff5e8;
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: #fde3e3;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #e0e0e0;
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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: () => {
|
||||
this.busy = false;
|
||||
this.notify('Request failed — check the account is active and the cheque-book facility is enabled', 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user