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:
Jaanu423
2026-07-15 11:02:46 +05:30
parent b9856a1c93
commit 7abfa394df
40 changed files with 4892 additions and 2 deletions
@@ -0,0 +1,47 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ 'labels.inputs.Report Remaining Stock Lost/Stolen' | translate }}</h1>
<div mat-dialog-content>
<div class="rll-context">
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Series' | translate }}</span>
<span>{{ data.seriesCode }}</span>
</div>
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Office' | translate }}</span>
<span>{{ data.officeName }}</span>
</div>
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Remaining Range' | translate }}</span>
<span>{{ data.nextSerialNo }} – {{ data.rangeEnd }}</span>
</div>
</div>
<form [formGroup]="reportLostForm" (ngSubmit)="submit()" class="rll-form">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Serial No' | translate }}</mat-label>
<input matInput type="number" formControlName="fromSerialNo" required />
<mat-hint>{{
'labels.text.Only a suffix of the remaining undrawn range can be reported' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" required />
</mat-form-field>
</div>
</form>
</div>
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="warn" [disabled]="reportLostForm.invalid || busy" (click)="submit()">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,34 @@
/**
* 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/.
*/
.rll-context {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px 20px;
min-width: 420px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
.rll-field {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.7rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
}
@@ -0,0 +1,90 @@
/**
* 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, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../../chequebook.service';
/** Popup shown when "Report Lost" is clicked on a stock lot row — reports the undrawn suffix as lost/stolen. */
@Component({
selector: 'mifosx-report-lot-lost-dialog',
templateUrl: './report-lot-lost-dialog.component.html',
styleUrls: ['./report-lot-lost-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class ReportLotLostDialogComponent {
dialogRef = inject<MatDialogRef<ReportLotLostDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private formBuilder = inject(UntypedFormBuilder);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
reportLostForm: UntypedFormGroup;
constructor() {
this.reportLostForm = this.formBuilder.group({
fromSerialNo: [
this.data.nextSerialNo,
Validators.required
],
reason: [
'',
Validators.required
]
});
}
submit(): void {
if (this.reportLostForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.reportLostForm.value;
const payload = { fromSerialNo: raw.fromSerialNo, reason: raw.reason, locale: 'en' };
this.chequeBookService.reportStockLotLost(this.data.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Remaining stock reported lost/stolen');
this.dialogRef.close({ reported: true });
},
error: () => {
this.busy = false;
this.notify(
'Failed to report stock lost — check the serial number is within the remaining undrawn range',
true
);
}
});
}
}
@@ -0,0 +1,328 @@
<!--
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 Stock' | translate }}</h2>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-tab-group>
<!-- ============ Stock Lots ============ -->
<mat-tab label="{{ 'labels.inputs.Stock Lots' | translate }}">
<div class="tab-body">
<!-- Denomination-wise availability roll-up: how many 25-leaf / 50-leaf books are on hand -->
@if (stockSummary.length > 0) {
<div class="summary-strip">
<div class="summary-title">{{ 'labels.inputs.Available Stock by Book Size' | translate }}</div>
<div class="summary-cards">
@for (row of stockSummary; track row.seriesCode + row.leavesPerBook) {
<div class="summary-card">
<div class="summary-books">{{ row.books }}</div>
<div class="summary-label">
{{ 'labels.inputs.books' | translate }} &times; {{ row.leavesPerBook }}-{{
'labels.inputs.leaf' | translate
}}
</div>
<div class="summary-series">{{ row.seriesCode }}</div>
</div>
}
</div>
</div>
}
<div class="tab-toolbar">
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Receive Stock' | translate }}
</button>
</div>
@if (showReceiveForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
<mat-select formControlName="seriesId" required (selectionChange)="onReceiveSeriesChange()">
@for (s of seriesList; track s.id) {
<mat-option [value]="s.id"
>{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{
'labels.inputs.leaf' | translate
}})</mat-option
>
}
</mat-select>
</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.Number of Books' | translate }}</mat-label>
<input matInput type="number" formControlName="noOfBooks" required />
@if (receiveLeavesPerBook) {
<mat-hint
>{{ receiveTotalLeaves }} {{ 'labels.inputs.leaves' | translate }} ({{
'labels.inputs.books' | translate
}}
&times; {{ receiveLeavesPerBook }})</mat-hint
>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Range Start' | translate }}</mat-label>
<input matInput type="number" formControlName="rangeStart" required />
<mat-hint>{{ 'labels.text.Auto-suggested to avoid overlap; editable' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Range End' | translate }}</mat-label>
<input matInput type="number" [value]="receiveRangeEnd ?? ''" readonly />
<mat-hint>{{ 'labels.text.Calculated from books x book size' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch / PO Reference' | translate }}</mat-label>
<input matInput formControlName="batchRef" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showReceiveForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="receiveForm.invalid || !receiveRangeEnd || busy"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of lotStatusOptions; track s) {
<button mat-stroked-button [class.active]="lotStatusFilter === s" (click)="filterLotsByStatus(s)">
{{ s }}
</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="displayedLots">
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
<td mat-cell *matCellDef="let l">{{ l.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
<td mat-cell *matCellDef="let l">{{ l.officeName }}</td>
</ng-container>
<ng-container matColumnDef="range">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
<td mat-cell *matCellDef="let l" class="num">{{ l.rangeStart }} – {{ l.rangeEnd }}</td>
</ng-container>
<ng-container matColumnDef="remainingLeaves">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Remaining' | translate }}</th>
<td mat-cell *matCellDef="let l" class="num">
<strong>{{ l.remainingBooks ?? 0 }}</strong> {{ 'labels.inputs.books' | translate }}
<span class="muted">× {{ l.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }}</span>
<span class="muted">({{ l.remainingLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
@if (l.lowStock) {
<span class="pill warn">{{ 'labels.text.Low Stock' | translate }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let l">
<span
class="pill"
[ngClass]="l.status === 'AVAILABLE' ? 'ok' : l.status === 'CANCELLED' ? 'bad' : 'neutral'"
>{{ l.status }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let l">
@if (l.status === 'AVAILABLE') {
<button mat-button color="warn" (click)="openReportLost(l)">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="lotColumns"></tr>
<tr mat-row *matRowDef="let row; columns: lotColumns"></tr>
</table>
@if (displayedLots.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (lotTotalRecords > 0) {
<mat-paginator
[length]="lotTotalRecords"
[pageSize]="lotPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="lotPageIndex"
showFirstLastButtons
(page)="onLotPageChange($event)"
></mat-paginator>
}
</div>
</div>
</mat-tab>
<!-- ============ Stock Transfers ============ -->
<mat-tab label="{{ 'labels.inputs.Stock Transfers' | translate }}">
<div class="tab-body">
<div class="tab-toolbar">
<button mat-raised-button color="primary" (click)="showDispatchForm = !showDispatchForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Dispatch Transfer' | translate }}
</button>
</div>
@if (showDispatchForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="dispatchForm" (ngSubmit)="submitDispatch()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
<mat-select formControlName="seriesId" required>
@for (s of seriesList; track s.id) {
<mat-option [value]="s.id">{{ s.seriesCode }} — {{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Office' | translate }}</mat-label>
<mat-select formControlName="fromOfficeId" 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.To Office' | translate }}</mat-label>
<mat-select formControlName="toOfficeId" 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.From Lot Id' | translate }}</mat-label>
<input matInput type="number" formControlName="fromLotId" required />
<mat-hint>{{
'labels.text.The stock-lot id to draw the transfer from (see Stock Lots tab)' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.No of Leaves' | translate }}</mat-label>
<input matInput type="number" formControlName="noOfLeaves" required />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showDispatchForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="dispatchForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of transferStatusOptions; track s) {
<button mat-stroked-button [class.active]="transferStatusFilter === s" (click)="filterTransfersByStatus(s)">
{{ s }}
</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="stockTransfers">
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="fromOfficeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.From Office' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.fromOfficeName }}</td>
</ng-container>
<ng-container matColumnDef="toOfficeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.To Office' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.toOfficeName }}</td>
</ng-container>
<ng-container matColumnDef="range">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
<td mat-cell *matCellDef="let t" class="num">{{ t.rangeStart }} – {{ t.rangeEnd }}</td>
</ng-container>
<ng-container matColumnDef="totalLeaves">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves' | translate }}</th>
<td mat-cell *matCellDef="let t" class="num">{{ t.totalLeaves }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let t">
<span
class="pill"
[ngClass]="t.status === 'RECEIVED' ? 'ok' : t.status === 'REJECTED' ? 'bad' : 'neutral'"
>{{ t.status }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="dispatchDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Dispatch Date' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.dispatchDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let t">
@if (t.status === 'DISPATCHED') {
<button mat-button color="primary" (click)="receiveTransfer(t)">
{{ 'labels.buttons.Receive' | translate }}
</button>
<button mat-button color="warn" (click)="rejectTransfer(t)">
{{ 'labels.buttons.Reject' | translate }}
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="transferColumns"></tr>
<tr mat-row *matRowDef="let row; columns: transferColumns"></tr>
</table>
@if (stockTransfers.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (transferTotalRecords > 0) {
<mat-paginator
[length]="transferTotalRecords"
[pageSize]="transferPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="transferPageIndex"
showFirstLastButtons
(page)="onTransferPageChange($event)"
></mat-paginator>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
@@ -0,0 +1,159 @@
/**
* 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;
}
.tab-body {
padding: 16px 0;
}
.tab-toolbar {
margin-bottom: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.summary-strip {
margin-bottom: 16px;
}
.summary-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: rgb(0 0 0 / 55%);
margin-bottom: 8px;
}
.summary-cards {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.summary-card {
min-width: 120px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 10px;
background: rgb(25 118 210 / 6%);
text-align: center;
}
.summary-books {
font-size: 1.6rem;
font-weight: 700;
color: #1976d2;
font-variant-numeric: tabular-nums;
}
.summary-label {
font-size: 0.8rem;
color: rgb(0 0 0 / 70%);
}
.summary-series {
font-size: 0.72rem;
color: rgb(0 0 0 / 50%);
margin-top: 2px;
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.8rem;
margin-left: 4px;
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
}
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%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
margin-left: 6px;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.warn {
background: #fff3cd;
color: #a16207;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,398 @@
/**
* 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;
}
/**
* Next unused serial WITHIN this series, so a fresh receipt continues that series' own numbering —
* each series is numbered independently (a separate stationery order from the press), so a different
* series' stock is not relevant here.
*/
private nextFreeSerial(): number {
const seriesId = this.receiveForm.get('seriesId')?.value;
const maxEnd = this.allStockLots
.filter((l) => l.seriesId === seriesId)
.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
return maxEnd + 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)
});
}
}