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:
+47
@@ -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>
|
||||
+34
@@ -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;
|
||||
}
|
||||
+90
@@ -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 }} × {{ 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> {{ '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
|
||||
}}
|
||||
× {{ 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> {{ '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)
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user