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