Receive Stock form now displays the actual highest-allocated serial across every series (not just silently prefilling the field), so the operator can see and trust the auto-suggested range start rather than taking it on faith. Fixes a real bug where item.decision (which InwardClearingItem.java always sets to the literal string "PENDING" at creation, never null) was checked with a plain truthy test everywhere a "has this been decided yet" branch was needed. Since "PENDING" is a non-empty string, that check was always true — silently breaking the Decide/View button label, the collapsed validation summary, the "Awaiting settlement" text, and the decision dialog itself (which never showed the actual Pass/Return form, even for still-pending items). Replaced with a proper isDecided() check everywhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
404 lines
12 KiB
TypeScript
404 lines
12 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 { RouterLink } from '@angular/router';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatTableModule } from '@angular/material/table';
|
|
import { MatTabsModule } from '@angular/material/tabs';
|
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
|
|
/** Custom Services */
|
|
import { ChequeBookService } from '../chequebook.service';
|
|
|
|
/** Dialog Components */
|
|
import { ReportLotLostDialogComponent } from './report-lot-lost-dialog/report-lot-lost-dialog.component';
|
|
|
|
/**
|
|
* Cheque stock: the unified stock-lot ledger (HO receipt / branch transfer-in share one drawdown-cursor
|
|
* concept) and the office-to-office transfer workflow (dispatch → receive-ack / reject) that draws from it.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-stock-management',
|
|
templateUrl: './stock-management.component.html',
|
|
styleUrls: ['./stock-management.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
RouterLink,
|
|
MatIconModule,
|
|
MatButtonModule,
|
|
MatTableModule,
|
|
MatTabsModule,
|
|
MatPaginatorModule,
|
|
MatSnackBarModule,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class StockManagementComponent implements OnInit {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private chequeBookService = inject(ChequeBookService);
|
|
private snackBar = inject(MatSnackBar);
|
|
private dialog = inject(MatDialog);
|
|
|
|
private notify(message: string, isError = false): void {
|
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
|
}
|
|
|
|
seriesList: any[] = [];
|
|
offices: any[] = [];
|
|
busy = false;
|
|
|
|
showReceiveForm = false;
|
|
showDispatchForm = false;
|
|
|
|
receiveForm: UntypedFormGroup;
|
|
dispatchForm: UntypedFormGroup;
|
|
|
|
lotColumns = [
|
|
'seriesCode',
|
|
'officeName',
|
|
'range',
|
|
'remainingLeaves',
|
|
'status',
|
|
'actions'
|
|
];
|
|
transferColumns = [
|
|
'seriesCode',
|
|
'fromOfficeName',
|
|
'toOfficeName',
|
|
'range',
|
|
'totalLeaves',
|
|
'status',
|
|
'dispatchDate',
|
|
'actions'
|
|
];
|
|
|
|
readonly lotStatusOptions = [
|
|
'AVAILABLE',
|
|
'DISPATCHED',
|
|
'EXHAUSTED',
|
|
'PARTIALLY_COMPROMISED',
|
|
'CANCELLED'
|
|
];
|
|
readonly transferStatusOptions = [
|
|
'DISPATCHED',
|
|
'RECEIVED',
|
|
'REJECTED'
|
|
];
|
|
|
|
/** Every stock lot, unfiltered — drives the denomination roll-up and the "next free serial" calculation,
|
|
* so it must never be limited to whatever page/status the display table happens to be showing. */
|
|
private allStockLots: any[] = [];
|
|
|
|
/** The lot table shown on screen — server-side paginated and status-filtered independently of allStockLots. */
|
|
displayedLots: any[] = [];
|
|
lotStatusFilter = '';
|
|
lotPageSize = 10;
|
|
lotPageIndex = 0;
|
|
lotTotalRecords = 0;
|
|
|
|
stockTransfers: any[] = [];
|
|
transferStatusFilter = '';
|
|
transferPageSize = 10;
|
|
transferPageIndex = 0;
|
|
transferTotalRecords = 0;
|
|
|
|
pageSizeOptions = [
|
|
10,
|
|
25,
|
|
50,
|
|
100
|
|
];
|
|
|
|
constructor() {
|
|
this.receiveForm = this.formBuilder.group({
|
|
seriesId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
officeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
noOfBooks: [
|
|
1,
|
|
Validators.required
|
|
],
|
|
rangeStart: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
batchRef: ['']
|
|
});
|
|
this.dispatchForm = this.formBuilder.group({
|
|
seriesId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
fromOfficeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
toOfficeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
fromLotId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
noOfLeaves: [
|
|
'',
|
|
Validators.required
|
|
]
|
|
});
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.loadAllStockLotsForCalculations();
|
|
this.loadDisplayedLots();
|
|
this.loadStockTransfers();
|
|
// populates dropdowns, 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 || []));
|
|
}
|
|
|
|
/** Denomination-wise roll-up of available books, e.g. "CTS-25 (25-leaf): 2000 books". */
|
|
stockSummary: { seriesCode: string; leavesPerBook: number; books: number; leaves: number }[] = [];
|
|
|
|
/** Unfiltered, every status, every page — feeds stockSummary and nextFreeSerial only, never the table. */
|
|
loadAllStockLotsForCalculations(): void {
|
|
this.chequeBookService.getStockLots(undefined, undefined, undefined, 0, 500).subscribe((res: any) => {
|
|
this.allStockLots = res?.pageItems ?? [];
|
|
this.buildSummary();
|
|
});
|
|
}
|
|
|
|
loadDisplayedLots(): void {
|
|
this.chequeBookService
|
|
.getStockLots(
|
|
undefined,
|
|
undefined,
|
|
this.lotStatusFilter || undefined,
|
|
this.lotPageIndex * this.lotPageSize,
|
|
this.lotPageSize
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.displayedLots = res?.pageItems ?? [];
|
|
this.lotTotalRecords = Number(res?.totalFilteredRecords ?? 0);
|
|
});
|
|
}
|
|
|
|
filterLotsByStatus(status: string): void {
|
|
this.lotStatusFilter = this.lotStatusFilter === status ? '' : status;
|
|
this.lotPageIndex = 0;
|
|
this.loadDisplayedLots();
|
|
}
|
|
|
|
onLotPageChange(event: PageEvent): void {
|
|
this.lotPageSize = event.pageSize;
|
|
this.lotPageIndex = event.pageIndex;
|
|
this.loadDisplayedLots();
|
|
}
|
|
|
|
private buildSummary(): void {
|
|
const map = new Map<string, { seriesCode: string; leavesPerBook: number; books: number; leaves: number }>();
|
|
for (const lot of this.allStockLots) {
|
|
// only stock that can actually be issued/transferred counts toward availability
|
|
if (lot.status !== 'AVAILABLE') {
|
|
continue;
|
|
}
|
|
const key = `${lot.seriesCode}|${lot.leavesPerBook}`;
|
|
const row = map.get(key) || { seriesCode: lot.seriesCode, leavesPerBook: lot.leavesPerBook, books: 0, leaves: 0 };
|
|
row.books += lot.remainingBooks || 0;
|
|
row.leaves += lot.remainingLeaves || 0;
|
|
map.set(key, row);
|
|
}
|
|
this.stockSummary = Array.from(map.values()).sort((a, b) => (a.leavesPerBook || 0) - (b.leavesPerBook || 0));
|
|
}
|
|
|
|
loadStockTransfers(): void {
|
|
this.chequeBookService
|
|
.getStockTransfers(
|
|
this.transferStatusFilter || undefined,
|
|
this.transferPageIndex * this.transferPageSize,
|
|
this.transferPageSize
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.stockTransfers = res?.pageItems ?? [];
|
|
this.transferTotalRecords = Number(res?.totalFilteredRecords ?? 0);
|
|
});
|
|
}
|
|
|
|
filterTransfersByStatus(status: string): void {
|
|
this.transferStatusFilter = this.transferStatusFilter === status ? '' : status;
|
|
this.transferPageIndex = 0;
|
|
this.loadStockTransfers();
|
|
}
|
|
|
|
onTransferPageChange(event: PageEvent): void {
|
|
this.transferPageSize = event.pageSize;
|
|
this.transferPageIndex = event.pageIndex;
|
|
this.loadStockTransfers();
|
|
}
|
|
|
|
/** The series currently chosen in the receive form. */
|
|
get receiveSeries(): any {
|
|
return this.seriesList.find((s) => s.id === this.receiveForm.get('seriesId')?.value);
|
|
}
|
|
|
|
get receiveLeavesPerBook(): number {
|
|
return this.receiveSeries?.leavesPerBook || 0;
|
|
}
|
|
|
|
/** Leaves to receive = books x this series' book size. */
|
|
get receiveTotalLeaves(): number {
|
|
const books = Number(this.receiveForm.get('noOfBooks')?.value) || 0;
|
|
return books * this.receiveLeavesPerBook;
|
|
}
|
|
|
|
/** Derived — the last serial covered by this receipt, so the operator never types it. */
|
|
get receiveRangeEnd(): number | null {
|
|
const start = Number(this.receiveForm.get('rangeStart')?.value);
|
|
const leaves = this.receiveTotalLeaves;
|
|
if (!start || !leaves) {
|
|
return null;
|
|
}
|
|
return start + leaves - 1;
|
|
}
|
|
|
|
/** Highest leaf-serial allocated so far, across every series/office — the actual number the "Range Start"
|
|
* suggestion is based on. Shown directly in the form (not just silently applied to the field) so the operator
|
|
* can see and trust the reasoning instead of taking a pre-filled number on faith. */
|
|
get globalHighestAllocated(): number {
|
|
return this.allStockLots.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
|
|
}
|
|
|
|
/**
|
|
* Next unused serial across EVERY series, not just the one being received — a real bank allocates leaf-serial
|
|
* numbers from one bank-wide numbering authority, so two different cheque designs never legitimately reuse the
|
|
* same range. Suggesting a start that only avoids the same series' own lots is exactly what let two series end
|
|
* up overlapping (and, once both got issued to the same account, an unresolvable serial collision) before the
|
|
* backend's own overlap check was widened to match (ChequeStockLotRepository.countOverlappingAnySeries).
|
|
*/
|
|
private nextFreeSerial(): number {
|
|
return this.globalHighestAllocated + 1;
|
|
}
|
|
|
|
onReceiveSeriesChange(): void {
|
|
// pre-fill a non-overlapping start; operator can still override
|
|
this.receiveForm.patchValue({ rangeStart: this.nextFreeSerial() });
|
|
}
|
|
|
|
private reloadLots(): void {
|
|
this.loadAllStockLotsForCalculations();
|
|
this.loadDisplayedLots();
|
|
}
|
|
|
|
submitReceive(): void {
|
|
if (this.receiveForm.invalid || this.busy || !this.receiveRangeEnd) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
const raw = this.receiveForm.value;
|
|
const payload: any = {
|
|
seriesId: raw.seriesId,
|
|
officeId: raw.officeId,
|
|
rangeStart: raw.rangeStart,
|
|
rangeEnd: this.receiveRangeEnd,
|
|
locale: 'en'
|
|
};
|
|
if (raw.batchRef) payload.batchRef = raw.batchRef;
|
|
|
|
this.chequeBookService.receiveStock(payload).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.showReceiveForm = false;
|
|
this.receiveForm.reset({ noOfBooks: 1 });
|
|
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
|
this.reloadLots();
|
|
},
|
|
error: () => {
|
|
this.busy = false;
|
|
this.notify('Failed to receive stock — check the leaf range does not overlap existing stock', true);
|
|
}
|
|
});
|
|
}
|
|
|
|
openReportLost(lot: any): void {
|
|
this.dialog
|
|
.open(ReportLotLostDialogComponent, { data: lot, width: '520px' })
|
|
.afterClosed()
|
|
.subscribe((result: any) => {
|
|
if (result?.reported) {
|
|
this.reloadLots();
|
|
}
|
|
});
|
|
}
|
|
|
|
submitDispatch(): void {
|
|
if (this.dispatchForm.invalid || this.busy) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
const raw = this.dispatchForm.value;
|
|
const payload = {
|
|
seriesId: raw.seriesId,
|
|
fromOfficeId: raw.fromOfficeId,
|
|
toOfficeId: raw.toOfficeId,
|
|
fromLotId: raw.fromLotId,
|
|
noOfLeaves: raw.noOfLeaves,
|
|
locale: 'en'
|
|
};
|
|
this.chequeBookService.dispatchStockTransfer(payload).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.showDispatchForm = false;
|
|
this.dispatchForm.reset();
|
|
this.notify('Stock transfer dispatched');
|
|
this.reloadLots();
|
|
this.loadStockTransfers();
|
|
},
|
|
error: () => {
|
|
this.busy = false;
|
|
this.notify('Failed to dispatch — check the lot has enough remaining leaves', true);
|
|
}
|
|
});
|
|
}
|
|
|
|
receiveTransfer(transfer: any): void {
|
|
this.chequeBookService.receiveStockTransfer(transfer.id).subscribe({
|
|
next: () => {
|
|
this.notify('Transfer receipt acknowledged');
|
|
this.reloadLots();
|
|
this.loadStockTransfers();
|
|
},
|
|
error: () => this.notify('Failed to acknowledge receipt', true)
|
|
});
|
|
}
|
|
|
|
rejectTransfer(transfer: any): void {
|
|
this.chequeBookService.rejectStockTransfer(transfer.id).subscribe({
|
|
next: () => {
|
|
this.notify('Transfer rejected');
|
|
this.loadStockTransfers();
|
|
},
|
|
error: () => this.notify('Failed to reject transfer', true)
|
|
});
|
|
}
|
|
}
|