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,145 @@
|
||||
<!--
|
||||
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 Book' | translate }} #{{ book.id }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="info-card">
|
||||
<mat-card-content>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Account' | translate }}</span>
|
||||
<span>
|
||||
{{ book.accountNo || book.accountId }}
|
||||
@if (book.clientName) {
|
||||
— {{ book.clientName }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Account Type' | translate }}</span
|
||||
><span>{{ book.accountType }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
|
||||
><span>{{ book.officeName }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
|
||||
><span>{{ book.seriesCode || '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Leaf Range' | translate }}</span>
|
||||
<span>{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Quantity' | translate }}</span
|
||||
><span>{{ book.noOfBooks }} {{ 'labels.inputs.books' | translate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
|
||||
><span>{{ book.status }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Issue Date' | translate }}</span
|
||||
><span>{{ book.issueDate ? (book.issueDate | dateFormat) : '–' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (book.status === 'ISSUED') {
|
||||
<div class="action-toolbar">
|
||||
<button mat-stroked-button color="warn" (click)="startAction('stoppayment')">
|
||||
{{ 'labels.buttons.Stop Payment' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAction('revokestoppayment')">
|
||||
{{ 'labels.buttons.Revoke Stop Payment' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" (click)="startAction('reportlost')">
|
||||
{{ 'labels.buttons.Report Lost' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAction('cancelleaf')">
|
||||
{{ 'labels.buttons.Cancel Leaf' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button color="primary" (click)="startAction('markused')">
|
||||
{{ 'labels.buttons.Mark as Used' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeAction) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="actionForm" (ngSubmit)="submitAction()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Leaf Seq' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="fromLeafSeq" />
|
||||
<mat-hint>{{ 'labels.text.Leave blank for the whole book' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.To Leaf Seq' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="toLeafSeq" />
|
||||
</mat-form-field>
|
||||
@if (activeAction !== 'revokestoppayment') {
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="reason" [required]="activeAction !== 'markused'" />
|
||||
@if (activeAction === 'markused') {
|
||||
<mat-hint>{{
|
||||
'labels.text.Optional — e.g. cleared per bank statement dated ...' | translate
|
||||
}}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="activeAction = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" type="submit" [disabled]="actionForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="leaf-grid-wrapper mat-elevation-z1">
|
||||
<div class="leaf-grid">
|
||||
@for (l of leaves; track l.leafSerialNo) {
|
||||
<button
|
||||
type="button"
|
||||
class="serial-circle"
|
||||
[ngClass]="
|
||||
l.status === 'ACTIVE'
|
||||
? 'ok'
|
||||
: l.status === 'USED'
|
||||
? 'used'
|
||||
: l.status === 'STOP_PAYMENT'
|
||||
? 'warn'
|
||||
: l.status === 'LOST'
|
||||
? 'bad'
|
||||
: 'neutral'
|
||||
"
|
||||
(click)="selectLeaf(l)"
|
||||
>
|
||||
{{ l.leafSerialNo }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (leaves.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 24px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.action-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 20px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.leaf-grid-wrapper {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.leaf-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.serial-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentcolor;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
&.ok {
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.used {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Observable } from 'rxjs';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { ChequeBookService } from '../chequebook.service';
|
||||
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
|
||||
|
||||
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused';
|
||||
|
||||
/**
|
||||
* One issued cheque book: its leaf register, and the five leaf-range actions (stop-payment, revoke,
|
||||
* report-lost, cancel-leaf, mark-used) — each defaults to the whole book when no range is given.
|
||||
* "Mark as used" is a manual record (this CBS has no clearing/presentment integration to detect it itself)
|
||||
* — a branch sets it once it sees a leaf clear on the bank/clearing statement.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cheque-book-detail',
|
||||
templateUrl: './cheque-book-detail.component.html',
|
||||
styleUrls: ['./cheque-book-detail.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ChequeBookDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
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 });
|
||||
}
|
||||
|
||||
bookId!: string;
|
||||
book: any = {};
|
||||
leaves: any[] = [];
|
||||
busy = false;
|
||||
|
||||
activeAction: LeafAction | null = null;
|
||||
actionForm: UntypedFormGroup;
|
||||
|
||||
constructor() {
|
||||
this.actionForm = this.formBuilder.group({
|
||||
fromLeafSeq: [''],
|
||||
toLeafSeq: [''],
|
||||
reason: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.bookId = this.route.snapshot.paramMap.get('id') as string;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
loadAll(): void {
|
||||
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => (this.book = data || {}));
|
||||
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
|
||||
this.leaves = data || [];
|
||||
});
|
||||
}
|
||||
|
||||
selectLeaf(leaf: any): void {
|
||||
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
||||
}
|
||||
|
||||
startAction(action: LeafAction): void {
|
||||
this.activeAction = this.activeAction === action ? null : action;
|
||||
this.actionForm.reset();
|
||||
const reasonControl = this.actionForm.get('reason');
|
||||
if (action === 'revokestoppayment' || action === 'markused') {
|
||||
reasonControl?.clearValidators();
|
||||
} else {
|
||||
reasonControl?.setValidators(Validators.required);
|
||||
}
|
||||
reasonControl?.updateValueAndValidity();
|
||||
}
|
||||
|
||||
submitAction(): void {
|
||||
if (this.actionForm.invalid || this.busy || !this.activeAction) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.actionForm.value;
|
||||
const payload: any = { locale: 'en' };
|
||||
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
|
||||
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
|
||||
if (raw.reason) payload.reason = raw.reason;
|
||||
|
||||
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
|
||||
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
|
||||
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
|
||||
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
|
||||
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
|
||||
markused: (id, p) => this.chequeBookService.markUsed(id, p)
|
||||
};
|
||||
const call = actionCalls[this.activeAction](this.bookId, payload);
|
||||
|
||||
call.subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.activeAction = null;
|
||||
this.notify('Action applied');
|
||||
this.loadAll();
|
||||
},
|
||||
error: () => {
|
||||
this.busy = false;
|
||||
this.notify(
|
||||
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
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>
|
||||
<span
|
||||
class="serial-circle"
|
||||
[ngClass]="
|
||||
data.status === 'ACTIVE'
|
||||
? 'ok'
|
||||
: data.status === 'USED'
|
||||
? 'used'
|
||||
: data.status === 'STOP_PAYMENT'
|
||||
? 'warn'
|
||||
: data.status === 'LOST'
|
||||
? 'bad'
|
||||
: 'neutral'
|
||||
"
|
||||
>{{ data.leafSerialNo }}</span
|
||||
>
|
||||
</h1>
|
||||
<div mat-dialog-content>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
|
||||
><span>{{ data.leafSerialNo }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
|
||||
><span>{{ data.status }}</span>
|
||||
</div>
|
||||
@if (data.stopReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
|
||||
><span>{{ data.stopReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.lostReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
|
||||
><span>{{ data.lostReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.cancelledReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
|
||||
><span>{{ data.cancelledReason }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (data.usedReason) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
|
||||
><span>{{ data.usedReason }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||
</mat-dialog-actions>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px 24px;
|
||||
min-width: 280px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.k {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.serial-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentcolor;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.ok {
|
||||
color: #28c07a;
|
||||
}
|
||||
|
||||
&.used {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Popup shown when a leaf circle in the cheque-book detail grid is clicked. */
|
||||
@Component({
|
||||
selector: 'mifosx-leaf-detail-dialog',
|
||||
templateUrl: './leaf-detail-dialog.component.html',
|
||||
styleUrls: ['./leaf-detail-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class LeafDetailDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<LeafDetailDialogComponent>>(MatDialogRef);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
Reference in New Issue
Block a user