From 7abfa394df95e3dce0e07f54406f2377583d4678 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Wed, 15 Jul 2026 11:02:46 +0530 Subject: [PATCH] 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. --- src/app/app.module.ts | 2 + .../cheque-book-detail.component.html | 145 +++++++ .../cheque-book-detail.component.scss | 127 ++++++ .../cheque-book-detail.component.ts | 135 ++++++ .../leaf-detail-dialog.component.html | 64 +++ .../leaf-detail-dialog.component.scss | 59 +++ .../leaf-detail-dialog.component.ts | 39 ++ .../cheque-books/cheque-books.component.html | 239 +++++++++++ .../cheque-books/cheque-books.component.scss | 128 ++++++ .../cheque-books/cheque-books.component.ts | 362 ++++++++++++++++ .../cheque-series.component.html | 136 ++++++ .../cheque-series.component.scss | 93 ++++ .../cheque-series/cheque-series.component.ts | 176 ++++++++ .../chequebook-dashboard.component.html | 29 ++ .../chequebook-dashboard.component.scss | 68 +++ .../chequebook-dashboard.component.ts | 72 ++++ .../chequebook/chequebook-routing.module.ts | 69 +++ src/app/chequebook/chequebook.module.ts | 46 ++ src/app/chequebook/chequebook.service.ts | 221 ++++++++++ .../leaf-lookup/leaf-lookup.component.html | 128 ++++++ .../leaf-lookup/leaf-lookup.component.scss | 125 ++++++ .../leaf-lookup/leaf-lookup.component.ts | 81 ++++ .../report-lot-lost-dialog.component.html | 47 +++ .../report-lot-lost-dialog.component.scss | 34 ++ .../report-lot-lost-dialog.component.ts | 90 ++++ .../stock-management.component.html | 328 +++++++++++++++ .../stock-management.component.scss | 159 +++++++ .../stock-management.component.ts | 398 ++++++++++++++++++ src/app/core/shell/shell.component.html | 6 + .../core/shell/sidenav/sidenav.component.html | 13 + src/app/shared/icons.module.ts | 8 +- .../chequebook-requests.component.html | 151 +++++++ .../chequebook-requests.component.scss | 374 ++++++++++++++++ .../chequebook-requests.component.ts | 222 ++++++++++ .../request-detail-dialog.component.html | 73 ++++ .../request-detail-dialog.component.scss | 178 ++++++++ .../request-detail-dialog.component.ts | 170 ++++++++ .../checker-inbox-and-tasks.component.html | 12 + src/app/tasks/tasks-routing.module.ts | 6 + src/assets/translations/en-US.json | 81 ++++ 40 files changed, 4892 insertions(+), 2 deletions(-) create mode 100644 src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html create mode 100644 src/app/chequebook/cheque-book-detail/cheque-book-detail.component.scss create mode 100644 src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts create mode 100644 src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.html create mode 100644 src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.scss create mode 100644 src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.ts create mode 100644 src/app/chequebook/cheque-books/cheque-books.component.html create mode 100644 src/app/chequebook/cheque-books/cheque-books.component.scss create mode 100644 src/app/chequebook/cheque-books/cheque-books.component.ts create mode 100644 src/app/chequebook/cheque-series/cheque-series.component.html create mode 100644 src/app/chequebook/cheque-series/cheque-series.component.scss create mode 100644 src/app/chequebook/cheque-series/cheque-series.component.ts create mode 100644 src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.html create mode 100644 src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.scss create mode 100644 src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.ts create mode 100644 src/app/chequebook/chequebook-routing.module.ts create mode 100644 src/app/chequebook/chequebook.module.ts create mode 100644 src/app/chequebook/chequebook.service.ts create mode 100644 src/app/chequebook/leaf-lookup/leaf-lookup.component.html create mode 100644 src/app/chequebook/leaf-lookup/leaf-lookup.component.scss create mode 100644 src/app/chequebook/leaf-lookup/leaf-lookup.component.ts create mode 100644 src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.html create mode 100644 src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.scss create mode 100644 src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.ts create mode 100644 src/app/chequebook/stock-management/stock-management.component.html create mode 100644 src/app/chequebook/stock-management/stock-management.component.scss create mode 100644 src/app/chequebook/stock-management/stock-management.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 09d2415c2..4fac8d9f9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -36,6 +36,7 @@ import { ClientsModule } from './clients/clients.module'; import { GroupsModule } from './groups/groups.module'; import { CentersModule } from './centers/centers.module'; import { AccountingModule } from './accounting/accounting.module'; +import { ChequeBookModule } from './chequebook/chequebook.module'; import { SystemModule } from './system/system.module'; import { ProductsModule } from './products/products.module'; import { OrganizationModule } from './organization/organization.module'; @@ -142,6 +143,7 @@ export function HttpLoaderFactory(http: HttpClient) { GroupsModule, CentersModule, AccountingModule, + ChequeBookModule, SystemModule, ProductsModule, OrganizationModule, diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html new file mode 100644 index 000000000..0afd956de --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html @@ -0,0 +1,145 @@ + + +
+ + + + +
+
+ {{ 'labels.inputs.Account' | translate }} + + {{ book.accountNo || book.accountId }} + @if (book.clientName) { + — {{ book.clientName }} + } + +
+
+ {{ 'labels.inputs.Account Type' | translate }}{{ book.accountType }} +
+
+ {{ 'labels.inputs.Office' | translate }}{{ book.officeName }} +
+
+ {{ 'labels.inputs.Series' | translate }}{{ book.seriesCode || '–' }} +
+
+ {{ 'labels.inputs.Leaf Range' | translate }} + {{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '–' }} +
+
+ {{ 'labels.inputs.Quantity' | translate }}{{ book.noOfBooks }} {{ 'labels.inputs.books' | translate }} +
+
+ {{ 'labels.inputs.Status' | translate }}{{ book.status }} +
+
+ {{ 'labels.inputs.Issue Date' | translate }}{{ book.issueDate ? (book.issueDate | dateFormat) : '–' }} +
+
+
+
+ + @if (book.status === 'ISSUED') { +
+ + + + + +
+ } + + @if (activeAction) { + + +
+
+ + {{ 'labels.inputs.From Leaf Seq' | translate }} + + {{ 'labels.text.Leave blank for the whole book' | translate }} + + + {{ 'labels.inputs.To Leaf Seq' | translate }} + + + @if (activeAction !== 'revokestoppayment') { + + {{ 'labels.inputs.Reason' | translate }} + + @if (activeAction === 'markused') { + {{ + 'labels.text.Optional — e.g. cleared per bank statement dated ...' | translate + }} + } + + } +
+
+ + +
+
+
+
+ } + +
+
+ @for (l of leaves; track l.leafSerialNo) { + + } +
+ @if (leaves.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } +
+
diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.scss b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.scss new file mode 100644 index 000000000..a994d762f --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.scss @@ -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%); +} diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts new file mode 100644 index 000000000..db69eb49f --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts @@ -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 Observable> = { + 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 + ); + } + }); + } +} diff --git a/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.html b/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.html new file mode 100644 index 000000000..0712eb0e1 --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.html @@ -0,0 +1,64 @@ + + +

+ {{ data.leafSerialNo }} +

+
+
+
+ {{ 'labels.inputs.Serial No' | translate }}{{ data.leafSerialNo }} +
+
+ {{ 'labels.inputs.Status' | translate }}{{ data.status }} +
+ @if (data.stopReason) { +
+ {{ 'labels.inputs.Stop Reason' | translate }}{{ data.stopReason }} +
+ } + @if (data.lostReason) { +
+ {{ 'labels.inputs.Lost Reason' | translate }}{{ data.lostReason }} +
+ } + @if (data.cancelledReason) { +
+ {{ 'labels.inputs.Cancelled Reason' | translate }}{{ data.cancelledReason }} +
+ } + @if (data.usedReason) { +
+ {{ 'labels.inputs.Used Reason' | translate }}{{ data.usedReason }} +
+ } +
+
+ + + diff --git a/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.scss b/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.scss new file mode 100644 index 000000000..05a48e303 --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.scss @@ -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; + } +} diff --git a/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.ts b/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.ts new file mode 100644 index 000000000..5a55d89ad --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component.ts @@ -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); + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/chequebook/cheque-books/cheque-books.component.html b/src/app/chequebook/cheque-books/cheque-books.component.html new file mode 100644 index 000000000..7e5ea6438 --- /dev/null +++ b/src/app/chequebook/cheque-books/cheque-books.component.html @@ -0,0 +1,239 @@ + + +
+ + +
+ @for (s of ['REQUESTED', 'ISSUED', 'REJECTED', 'WITHDRAWN']; track s) { + + } +
+ + @if (showRequestForm) { + + +
+
+ + {{ 'labels.inputs.Account Type' | translate }} + + {{ 'labels.inputs.Savings' | translate }} + {{ 'labels.inputs.Current' | translate }} + + + + {{ 'labels.inputs.Account Number' | translate }} + + @if (searchingAccount) { + {{ 'labels.text.Searching...' | translate }} + } @else if (resolvedAccount) { + + ✓ {{ resolvedAccount.accountNo }} + @if (resolvedAccount.clientName) { + — {{ resolvedAccount.clientName }} + } + @if (resolvedAccount.status) { + ({{ resolvedAccount.status }}) + } + + } @else if (accountError) { + {{ accountError | translate }} + } @else { + {{ 'labels.text.Type at least 4 digits of the account number' | translate }} + } + + + {{ 'labels.inputs.Office' | translate }} + + @for (office of offices; track office.id) { + {{ office.name }} + } + + + + {{ 'labels.inputs.Series' | translate }} + + @for (s of requestableSeries; track s.id) { + + {{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }}, + {{ availableBooksForRequestSeries(s.id) }} {{ 'labels.inputs.books available' | translate }}) + + } + + {{ 'labels.text.Only series applicable to this account type are shown' | translate }} + + + {{ 'labels.inputs.Number of Books' | translate }} + + {{ 'labels.text.Leaves handed over = books x the series book size' | translate }} + +
+
+ + +
+
+
+
+ } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Id' | translate }}{{ b.id }}{{ 'labels.inputs.Account Type' | translate }}{{ b.accountType }}{{ 'labels.inputs.Account' | translate }} + {{ b.accountNo || b.accountId }} + @if (b.clientName) { + ({{ b.clientName }}) + } + {{ 'labels.inputs.Office' | translate }}{{ b.officeName }}{{ 'labels.inputs.Series' | translate }}{{ b.seriesCode }}{{ 'labels.inputs.Quantity' | translate }} + {{ b.noOfBooks }} {{ 'labels.inputs.books' | translate }} + @if (b.totalLeaves) { + ({{ b.totalLeaves }} {{ 'labels.inputs.leaves' | translate }}) + } + {{ 'labels.inputs.Status' | translate }} + {{ b.status }} + {{ 'labels.inputs.Requested Date' | translate }}{{ b.requestedDate | dateFormat }} + @if (b.status === 'REQUESTED') { + + + } + @if (b.status === 'ISSUED') { + + + } +
+ @if (books.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+ + @if (issueForBookId) { + + +

{{ 'labels.inputs.Issue Cheque Book' | translate }}

+

+ {{ 'labels.inputs.Requested' | translate }}: {{ issueForBook?.noOfBooks }} + {{ 'labels.inputs.books' | translate }} — {{ issueForBook?.seriesCode }} + + ({{ issueAvailableBooks }} {{ 'labels.inputs.books available' | translate }}) + +

+
+ + {{ 'labels.inputs.Customer collected the book now' | translate }} + +
+ + +
+
+
+
+ } + + @if (rejectForBookId) { + + +

{{ 'labels.inputs.Reject Cheque Book Request' | translate }}

+
+
+ + {{ 'labels.inputs.Rejection Reason' | translate }} + + +
+
+ + +
+
+
+
+ } +
diff --git a/src/app/chequebook/cheque-books/cheque-books.component.scss b/src/app/chequebook/cheque-books/cheque-books.component.scss new file mode 100644 index 000000000..16ca3c1fe --- /dev/null +++ b/src/app/chequebook/cheque-books/cheque-books.component.scss @@ -0,0 +1,128 @@ +/** + * 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; +} + +.header-actions { + display: flex; + gap: 12px; +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +.add-form { + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +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%; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background: rgb(0 0 0 / 3%); + } +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.muted { + color: rgb(0 0 0 / 50%); + font-size: 0.8rem; + margin-left: 4px; +} + +.issue-need { + margin: 0 0 8px; + font-size: 0.9rem; + color: rgb(0 0 0 / 70%); +} + +::ng-deep .ok-hint { + color: #28c07a; + font-weight: 600; +} + +::ng-deep .err-hint { + color: #d32f2f; + font-weight: 600; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} diff --git a/src/app/chequebook/cheque-books/cheque-books.component.ts b/src/app/chequebook/cheque-books/cheque-books.component.ts new file mode 100644 index 000000000..40a43a5ff --- /dev/null +++ b/src/app/chequebook/cheque-books/cheque-books.component.ts @@ -0,0 +1,362 @@ +/** + * 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 { Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ChequeBookService } from '../chequebook.service'; + +/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */ +@Component({ + selector: 'mifosx-cheque-books', + templateUrl: './cheque-books.component.html', + styleUrls: ['./cheque-books.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class ChequeBooksComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private chequeBookService = inject(ChequeBookService); + private router = inject(Router); + private snackBar = inject(MatSnackBar); + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + books: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + seriesList: any[] = []; + offices: any[] = []; + stockLots: any[] = []; + busy = false; + + showRequestForm = false; + issueForBookId: number | null = null; + issueForBook: any = null; + rejectForBookId: number | null = null; + statusFilter = ''; + + requestForm: UntypedFormGroup; + issueForm: UntypedFormGroup; + rejectForm: UntypedFormGroup; + + // account-number lookup for the request form + private accountSearch$ = new Subject(); + resolvedAccount: any = null; + searchingAccount = false; + accountError = ''; + + columns = [ + 'id', + 'accountType', + 'account', + 'officeName', + 'seriesCode', + 'quantity', + 'status', + 'requestedDate', + 'actions' + ]; + + constructor() { + this.requestForm = this.formBuilder.group({ + accountType: [ + 'SAVINGS', + Validators.required + ], + accountNo: [ + '', + Validators.required + ], + accountId: [ + null, + Validators.required + ], + officeId: [ + '', + Validators.required + ], + seriesId: [ + '', + Validators.required + ], + noOfBooks: [ + 1, + Validators.required + ] + }); + this.issueForm = this.formBuilder.group({ + collectionAcknowledged: [false] + }); + this.rejectForm = this.formBuilder.group({ + rejectionReason: [ + '', + Validators.required + ] + }); + } + + ngOnInit(): void { + this.loadBooks(); + this.loadStock(); + // populates a dropdown, 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 || [])); + + // account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module) + this.accountSearch$ + .pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((accountNo) => this.doAccountSearch(accountNo)); + } + + /** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */ + onAccountNoInput(value: string): void { + this.resolvedAccount = null; + this.accountError = ''; + this.requestForm.patchValue({ accountId: null }); + const trimmed = (value || '').trim(); + if (trimmed.length >= 4) { + this.accountSearch$.next(trimmed); + } + } + + /** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */ + onAccountTypeChange(): void { + this.resolvedAccount = null; + this.accountError = ''; + this.requestForm.patchValue({ accountId: null, seriesId: '' }); + const accountNo = (this.requestForm.get('accountNo')?.value || '').trim(); + if (accountNo.length >= 4) { + this.accountSearch$.next(accountNo); + } + } + + /** Series applicable to whichever account type is currently selected in the request form — every series is + * tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */ + get requestableSeries(): any[] { + const type = this.requestForm.get('accountType')?.value; + return this.seriesList.filter((s) => s.applicableAccountType === type); + } + + private doAccountSearch(accountNo: string): void { + const accountType = this.requestForm.get('accountType')?.value; + this.searchingAccount = true; + this.accountError = ''; + this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({ + next: (res: any) => { + this.searchingAccount = false; + const items = res?.pageItems || (Array.isArray(res) ? res : []); + const exact = items.find((i: any) => i.accountNo === accountNo); + const match = exact || (items.length === 1 ? items[0] : null); + if (match) { + this.resolvedAccount = { + id: match.id, + accountNo: match.accountNo, + clientName: match.clientName || match.clientDisplayName || match.groupName || '', + status: match.status?.value || '' + }; + this.requestForm.patchValue({ accountId: match.id }); + } else { + this.accountError = 'No account found with that number'; + } + }, + error: () => { + this.searchingAccount = false; + this.accountError = 'Account lookup failed'; + } + }); + } + + loadStock(): void { + // feeds availability calculations below, so fetch every available lot in one page rather than paging through it + this.chequeBookService + .getStockLots(undefined, undefined, 'AVAILABLE', 0, 500) + .subscribe((res: any) => (this.stockLots = res?.pageItems ?? [])); + } + + /** Whole books currently on hand for a series, optionally narrowed to one office. */ + availableBooksForSeries(seriesId: number, officeId?: number): number { + return this.stockLots + .filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE') + .reduce((sum, l) => sum + (l.remainingBooks || 0), 0); + } + + /** Availability at whichever office is currently chosen in the request form. */ + availableBooksForRequestSeries(seriesId: number): number { + return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value); + } + + loadBooks(): void { + this.chequeBookService + .getBooks( + undefined, + undefined, + undefined, + this.statusFilter || undefined, + this.pageIndex * this.pageSize, + this.pageSize + ) + .subscribe((res: any) => { + this.books = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.loadBooks(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.loadBooks(); + } + + openBook(book: any): void { + this.router.navigate([ + '/chequebook', + 'books', + book.id + ]); + } + + submitRequest(): void { + if (this.requestForm.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.requestForm.value; + const payload = { + accountType: raw.accountType, + accountId: raw.accountId, + officeId: raw.officeId, + seriesId: raw.seriesId, + noOfBooks: raw.noOfBooks, + locale: 'en' + }; + this.chequeBookService.requestBook(payload).subscribe({ + next: () => { + this.busy = false; + this.showRequestForm = false; + this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 }); + this.resolvedAccount = null; + this.accountError = ''; + this.notify('Submitted for approval — see Approvals > Cheque Book Requests'); + this.loadBooks(); + }, + error: () => { + this.busy = false; + this.notify('Request failed — check the account is active and the cheque-book facility is enabled', true); + } + }); + } + + toggleIssue(book: any): void { + const opening = this.issueForBookId !== book.id; + this.issueForBookId = opening ? book.id : null; + this.issueForBook = opening ? book : null; + this.issueForm.reset({ collectionAcknowledged: false }); + } + + /** Books available right now for the series this book was already requested against. */ + get issueAvailableBooks(): number { + return this.issueForBook?.seriesId + ? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId) + : 0; + } + + submitIssue(bookId: number): void { + if (this.issueForm.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.issueForm.value; + const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' }; + this.chequeBookService.issueBook(bookId, payload).subscribe({ + next: () => { + this.busy = false; + this.issueForBookId = null; + this.issueForBook = null; + this.notify('Cheque book issued'); + this.loadBooks(); + this.loadStock(); + }, + error: () => { + this.busy = false; + this.notify('Issue failed — check there is enough available stock for this series at the office', true); + } + }); + } + + toggleReject(book: any): void { + this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id; + this.rejectForm.reset(); + } + + submitReject(bookId: number): void { + if (this.rejectForm.invalid || this.busy) { + return; + } + this.busy = true; + const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' }; + this.chequeBookService.rejectBook(bookId, payload).subscribe({ + next: () => { + this.busy = false; + this.rejectForBookId = null; + this.notify('Cheque book request rejected'); + this.loadBooks(); + }, + error: () => { + this.busy = false; + this.notify('Reject failed', true); + } + }); + } + + withdraw(book: any): void { + this.chequeBookService.withdrawBook(book.id).subscribe({ + next: () => { + this.notify('Cheque book withdrawn'); + this.loadBooks(); + }, + error: () => this.notify('Withdraw failed', true) + }); + } +} diff --git a/src/app/chequebook/cheque-series/cheque-series.component.html b/src/app/chequebook/cheque-series/cheque-series.component.html new file mode 100644 index 000000000..f93505a66 --- /dev/null +++ b/src/app/chequebook/cheque-series/cheque-series.component.html @@ -0,0 +1,136 @@ + + +
+ + + @if (showForm) { + + +
+
+ + {{ 'labels.inputs.Series Code' | translate }} + + + + {{ 'labels.inputs.Name' | translate }} + + + + {{ 'labels.inputs.Leaves Per Book' | translate }} + + + + {{ 'labels.inputs.Applicable Account Type' | translate }} + + {{ 'labels.inputs.Savings Only' | translate }} + {{ 'labels.inputs.Current Only' | translate }} + + {{ 'labels.text.SB and CA cheque stationery differ' | translate }} + + + {{ 'labels.inputs.Reorder Level' | translate }} + + {{ 'labels.text.Low-stock alert threshold (remaining leaves)' | translate }} + +
+ {{ + 'labels.inputs.CTS-2010 Compliant' | translate + }} +
+ + +
+
+
+
+ } + +
+ @for (s of ['ACTIVE', 'INACTIVE']; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Series Code' | translate }}{{ s.seriesCode }}{{ 'labels.inputs.Name' | translate }}{{ s.name }}{{ 'labels.inputs.Leaves Per Book' | translate }}{{ s.leavesPerBook }}{{ 'labels.inputs.Applicable Account Type' | translate }} + {{ s.applicableAccountType }} + {{ 'labels.inputs.CTS-2010 Compliant' | translate }} + {{ s.ctsCompliant ? 'Yes' : 'No' }} + {{ 'labels.inputs.Reorder Level' | translate }}{{ s.reorderLevel ?? '-' }}{{ 'labels.inputs.Status' | translate }} + {{ s.active ? 'Active' : 'Inactive' }} + + @if (s.active) { + + } +
+ @if (seriesList.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+
diff --git a/src/app/chequebook/cheque-series/cheque-series.component.scss b/src/app/chequebook/cheque-series/cheque-series.component.scss new file mode 100644 index 000000000..9b57c5f8f --- /dev/null +++ b/src/app/chequebook/cheque-series/cheque-series.component.scss @@ -0,0 +1,93 @@ +/** + * 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; +} + +.header-actions { + display: flex; + gap: 12px; +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +.add-form { + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +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; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} diff --git a/src/app/chequebook/cheque-series/cheque-series.component.ts b/src/app/chequebook/cheque-series/cheque-series.component.ts new file mode 100644 index 000000000..77e9b8ebc --- /dev/null +++ b/src/app/chequebook/cheque-series/cheque-series.component.ts @@ -0,0 +1,176 @@ +/** + * 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 { 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'; +import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; + +/** Cheque-leaf stationery master — leaves per book, CTS-2010 compliance, low-stock reorder threshold. */ +@Component({ + selector: 'mifosx-cheque-series', + templateUrl: './cheque-series.component.html', + styleUrls: ['./cheque-series.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class ChequeSeriesComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private chequeBookService = inject(ChequeBookService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + seriesList: any[] = []; + showForm = false; + busy = false; + + statusFilter = ''; + pageSize = 10; + pageIndex = 0; + totalRecords = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + + seriesForm: UntypedFormGroup; + + columns = [ + 'seriesCode', + 'name', + 'leavesPerBook', + 'applicableAccountType', + 'ctsCompliant', + 'reorderLevel', + 'active', + 'actions' + ]; + + constructor() { + this.seriesForm = this.formBuilder.group({ + seriesCode: [ + '', + Validators.required + ], + name: [ + '', + Validators.required + ], + leavesPerBook: [ + 25, + Validators.required + ], + applicableAccountType: [ + '', + Validators.required + ], + ctsCompliant: [true], + reorderLevel: [10] + }); + } + + ngOnInit(): void { + this.loadSeries(); + } + + loadSeries(): void { + this.chequeBookService + .getSeries(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize) + .subscribe((res: any) => { + this.seriesList = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.loadSeries(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.loadSeries(); + } + + submit(): void { + if (this.seriesForm.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.seriesForm.value; + const payload: any = { + seriesCode: raw.seriesCode, + name: raw.name, + leavesPerBook: raw.leavesPerBook, + applicableAccountType: raw.applicableAccountType, + ctsCompliant: !!raw.ctsCompliant, + locale: 'en' + }; + if (raw.reorderLevel !== '' && raw.reorderLevel != null) payload.reorderLevel = raw.reorderLevel; + + this.chequeBookService.createSeries(payload).subscribe({ + next: () => { + this.busy = false; + this.showForm = false; + this.seriesForm.reset({ leavesPerBook: 25, applicableAccountType: '', ctsCompliant: true, reorderLevel: 10 }); + this.notify('Submitted for approval — see Approvals > Cheque Book Requests'); + this.loadSeries(); + }, + error: () => { + this.busy = false; + this.notify('Failed to add cheque series', true); + } + }); + } + + deactivate(series: any): void { + const dialogRef = this.dialog.open(DeleteDialogComponent, { + data: { deleteContext: `cheque series ${series.seriesCode}` } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (!response?.delete) { + return; + } + this.chequeBookService.deactivateSeries(series.id).subscribe({ + next: () => { + this.notify('Submitted for approval — see Approvals > Cheque Book Requests'); + this.loadSeries(); + }, + error: () => this.notify('Failed to submit for approval', true) + }); + }); + } +} diff --git a/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.html b/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.html new file mode 100644 index 000000000..05def643c --- /dev/null +++ b/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.html @@ -0,0 +1,29 @@ + + +
+ + +
+ @for (screen of screens; track screen.label) { + + +
+ + + + {{ screen.label | translate }} +
+

{{ screen.description | translate }}

+
+
+ } +
+
diff --git a/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.scss b/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.scss new file mode 100644 index 000000000..8a994b5c7 --- /dev/null +++ b/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.scss @@ -0,0 +1,68 @@ +/** + * 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; +} + +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.cb-card { + cursor: pointer; + transition: + box-shadow 0.2s ease, + transform 0.2s ease; + + &:hover { + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + transform: translateY(-2px); + } +} + +.card-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} + +.card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 10px; + flex: 0 0 auto; + background: rgb(25 118 210 / 10%); + color: #1976d2; + font-size: 1.15rem; +} + +.card-title { + font-size: 1.05rem; + font-weight: 600; + line-height: 1.2; +} + +.card-desc { + color: rgb(0 0 0 / 60%); + font-size: 0.85rem; + margin: 0; +} diff --git a/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.ts b/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.ts new file mode 100644 index 000000000..c62da823d --- /dev/null +++ b/src/app/chequebook/chequebook-dashboard/chequebook-dashboard.component.ts @@ -0,0 +1,72 @@ +/** + * 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 } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCardModule } from '@angular/material/card'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Cheque Book Inventory landing page — one card per screen (series master, stock, requests/issuance, and + * the teller quick-lookup). + */ +@Component({ + selector: 'mifosx-chequebook-dashboard', + templateUrl: './chequebook-dashboard.component.html', + styleUrls: ['./chequebook-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + MatIconModule, + MatCardModule, + FaIconComponent + ] +}) +export class ChequeBookDashboardComponent { + screens = [ + { + route: [ + '/chequebook', + 'series' + ], + label: 'Cheque Series', + icon: 'list', + description: 'Stationery master — leaves per book, CTS-2010 compliance, low-stock reorder level' + }, + { + route: [ + '/chequebook', + 'stock' + ], + label: 'Stock Lots & Transfers', + icon: 'boxes-stacked', + description: 'Receive bulk stock from the press, dispatch to branches, acknowledge receipt' + }, + { + route: [ + '/chequebook', + 'books' + ], + label: 'Requests & Issuance', + icon: 'book', + description: 'Customer requisitions, issuance, stop-payment, lost/stolen hotlisting' + }, + { + route: [ + '/chequebook', + 'lookup' + ], + label: 'Leaf Lookup', + icon: 'magnifying-glass', + description: 'Counter check — is this cheque number safe to honour?' + } + ]; +} diff --git a/src/app/chequebook/chequebook-routing.module.ts b/src/app/chequebook/chequebook-routing.module.ts new file mode 100644 index 000000000..a8efbfcc7 --- /dev/null +++ b/src/app/chequebook/chequebook-routing.module.ts @@ -0,0 +1,69 @@ +/** + * 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 { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +/** Routing Imports */ +import { Route } from '../core/route/route.service'; + +/** Custom Components */ +import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component'; +import { ChequeSeriesComponent } from './cheque-series/cheque-series.component'; +import { StockManagementComponent } from './stock-management/stock-management.component'; +import { ChequeBooksComponent } from './cheque-books/cheque-books.component'; +import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component'; +import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component'; + +/** Cheque Book Inventory Routes */ +const routes: Routes = [ + Route.withShell([ + { + path: 'chequebook', + data: { title: 'Cheque Book Inventory', breadcrumb: 'Cheque Book Inventory' }, + children: [ + { + path: '', + component: ChequeBookDashboardComponent + }, + { + path: 'series', + component: ChequeSeriesComponent, + data: { title: 'Cheque Series', breadcrumb: 'Cheque Series' } + }, + { + path: 'stock', + component: StockManagementComponent, + data: { title: 'Cheque Stock', breadcrumb: 'Cheque Stock' } + }, + { + path: 'books', + component: ChequeBooksComponent, + data: { title: 'Cheque Books', breadcrumb: 'Cheque Books' } + }, + { + path: 'books/:id', + component: ChequeBookDetailComponent, + data: { title: 'Cheque Book', routeParamBreadcrumb: 'id' } + }, + { + path: 'lookup', + component: LeafLookupComponent, + data: { title: 'Leaf Lookup', breadcrumb: 'Leaf Lookup' } + } + ] + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ChequeBookRoutingModule {} diff --git a/src/app/chequebook/chequebook.module.ts b/src/app/chequebook/chequebook.module.ts new file mode 100644 index 000000000..db7514443 --- /dev/null +++ b/src/app/chequebook/chequebook.module.ts @@ -0,0 +1,46 @@ +/** + * 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 { NgModule } from '@angular/core'; + +/** Custom Modules */ +import { SharedModule } from '../shared/shared.module'; +import { DirectivesModule } from '../directives/directives.module'; +import { PipesModule } from 'app/pipes/pipes.module'; +import { ChequeBookRoutingModule } from './chequebook-routing.module'; + +/** Custom Components (standalone) */ +import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component'; +import { ChequeSeriesComponent } from './cheque-series/cheque-series.component'; +import { StockManagementComponent } from './stock-management/stock-management.component'; +import { ChequeBooksComponent } from './cheque-books/cheque-books.component'; +import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component'; +import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component'; + +/** + * Cheque Book Inventory Module. + * + * Stationery master, a unified stock ledger (HO receipt / branch transfer-in), account issuance with + * per-leaf serial tracking, stop-payment, and lost/stolen hotlisting for both issued and unissued stock. + */ +@NgModule({ + imports: [ + SharedModule, + DirectivesModule, + PipesModule, + ChequeBookRoutingModule, + ChequeBookDashboardComponent, + ChequeSeriesComponent, + StockManagementComponent, + ChequeBooksComponent, + ChequeBookDetailComponent, + LeafLookupComponent + ] +}) +export class ChequeBookModule {} diff --git a/src/app/chequebook/chequebook.service.ts b/src/app/chequebook/chequebook.service.ts new file mode 100644 index 000000000..c18fa2db0 --- /dev/null +++ b/src/app/chequebook/chequebook.service.ts @@ -0,0 +1,221 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * Cheque Book Inventory — stationery master, a unified stock ledger (HO receipt / branch transfer-in share + * one drawdown-cursor concept), account issuance with per-leaf serial tracking, stop-payment and + * lost/stolen hotlisting (both issued and unissued stock). + */ +@Injectable({ + providedIn: 'root' +}) +export class ChequeBookService { + private http = inject(HttpClient); + + // ----- Series ----- + + /** status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — always returns { pageItems, totalFilteredRecords }. */ + getSeries(status?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + return this.http.get('/chequeseries', { params }); + } + + createSeries(payload: any): Observable { + return this.http.post('/chequeseries', payload); + } + + updateSeries(id: string | number, payload: any): Observable { + return this.http.put(`/chequeseries/${id}`, payload); + } + + deactivateSeries(id: string | number): Observable { + return this.http.delete(`/chequeseries/${id}`); + } + + // ----- Stock lots ----- + + /** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */ + getStockLots( + officeId?: string | number, + seriesId?: string | number, + status?: string, + offset = 0, + limit = 20 + ): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (officeId) params = params.set('officeId', officeId); + if (seriesId) params = params.set('seriesId', seriesId); + if (status) params = params.set('status', status); + return this.http.get('/chequestocklots', { params }); + } + + receiveStock(payload: any): Observable { + return this.http.post('/chequestocklots', payload); + } + + reportStockLotLost(lotId: string | number, payload: any): Observable { + return this.http.post(`/chequestocklots/${lotId}`, payload, { + params: new HttpParams().set('command', 'reportlost') + }); + } + + // ----- Stock transfers ----- + + /** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */ + getStockTransfers(status?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + return this.http.get('/chequestocktransfers', { params }); + } + + dispatchStockTransfer(payload: any): Observable { + return this.http.post('/chequestocktransfers', payload); + } + + receiveStockTransfer(transferId: string | number): Observable { + return this.http.post( + `/chequestocktransfers/${transferId}`, + {}, + { params: new HttpParams().set('command', 'receive') } + ); + } + + rejectStockTransfer(transferId: string | number): Observable { + return this.http.post( + `/chequestocktransfers/${transferId}`, + {}, + { params: new HttpParams().set('command', 'reject') } + ); + } + + // ----- Cheque books ----- + + getBooks( + accountType?: string, + accountId?: string | number, + officeId?: string | number, + status?: string, + offset = 0, + limit = 20 + ): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (accountType) params = params.set('accountType', accountType); + if (accountId) params = params.set('accountId', accountId); + if (officeId) params = params.set('officeId', officeId); + if (status) params = params.set('status', status); + return this.http.get('/chequebooks', { params }); + } + + getBook(id: string | number): Observable { + return this.http.get(`/chequebooks/${id}`); + } + + getLeaves(bookId: string | number): Observable { + return this.http.get(`/chequebooks/${bookId}/leaves`); + } + + requestBook(payload: any): Observable { + return this.http.post('/chequebooks', payload); + } + + issueBook(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'issue') }); + } + + rejectBook(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reject') }); + } + + withdrawBook(bookId: string | number): Observable { + return this.http.post(`/chequebooks/${bookId}`, {}, { params: new HttpParams().set('command', 'withdraw') }); + } + + stopPayment(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { + params: new HttpParams().set('command', 'stoppayment') + }); + } + + revokeStopPayment(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { + params: new HttpParams().set('command', 'revokestoppayment') + }); + } + + reportBookLost(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reportlost') }); + } + + cancelLeaf(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'cancelleaf') }); + } + + /** Manually record that leaves cleared, once the branch sees them on the bank/clearing statement. */ + markUsed(bookId: string | number, payload: any): Observable { + return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'markused') }); + } + + // ----- Pending requests (bespoke maker-checker for series/stock/book creation) ----- + + /** + * A different user than the maker must approve/reject — enforced server-side, not just in this UI. + * Server-side paginated and date-filtered, matching the clerk disbursement/withdrawal request queues. + */ + getPendingRequests(status?: string, fromDate?: string, toDate?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/chequebook/pending-requests', { params }); + } + + getPendingRequest(id: string | number): Observable { + return this.http.get(`/chequebook/pending-requests/${id}`); + } + + approvePendingRequest(id: string | number): Observable { + return this.http.post( + `/chequebook/pending-requests/${id}`, + {}, + { params: new HttpParams().set('command', 'approve') } + ); + } + + rejectPendingRequest(id: string | number, payload: any): Observable { + return this.http.post(`/chequebook/pending-requests/${id}`, payload, { + params: new HttpParams().set('command', 'reject') + }); + } + + // ----- Leaf lookup ----- + + lookupLeaf(serialNo: string | number): Observable { + return this.http.get('/chequeleaves/lookup', { params: new HttpParams().set('serialNo', serialNo) }); + } + + // ----- Account lookup (by account number) ----- + + /** Search a Savings or Current account by its account number; returns a paginated list to match against. */ + searchAccountByNo(accountType: string, accountNo: string): Observable { + const base = accountType === 'CURRENT' ? '/currentaccounts' : '/savingsaccounts'; + const params = new HttpParams().set('accountNo', accountNo).set('offset', '0').set('limit', '20'); + return this.http.get(base, { params }); + } + + // ----- Shared template data ----- + + getOffices(): Observable { + return this.http.get('/offices'); + } +} diff --git a/src/app/chequebook/leaf-lookup/leaf-lookup.component.html b/src/app/chequebook/leaf-lookup/leaf-lookup.component.html new file mode 100644 index 000000000..9f3b775b2 --- /dev/null +++ b/src/app/chequebook/leaf-lookup/leaf-lookup.component.html @@ -0,0 +1,128 @@ + + +
+ + + + +
+ + {{ 'labels.inputs.Cheque Leaf Serial No' | translate }} + + + +
+
+
+ + @if (results.length > 1) { +
+ {{ + 'labels.text.More than one series has a leaf with this serial number — check the series code against the physical cheque.' + | translate + }} +
+ } + + @for (result of results; track result.seriesCode + '-' + result.leafSerialNo) { + + +
+ @if (result.seriesCode) { + {{ result.seriesCode }} + } + {{ result.status }} +
+
+
+ {{ 'labels.inputs.Serial No' | translate }}{{ result.leafSerialNo }} +
+
+ {{ 'labels.inputs.Series' | translate }}{{ result.seriesCode || '–' }} +
+ @if (result.chequeBookId) { +
+ {{ 'labels.inputs.Cheque Book' | translate }}#{{ result.chequeBookId }} +
+
+ {{ 'labels.inputs.Account' | translate }}{{ result.accountType }} {{ result.accountNo || result.accountId }} +
+ @if (result.clientName) { +
+ {{ 'labels.inputs.Client' | translate }}{{ result.clientName }} +
+ } + } @else { +
+ {{ + 'labels.text.This leaf was never issued to a customer — reported lost/stolen from stock.' | translate + }} +
+ } +
+ {{ 'labels.inputs.Office' | translate }}{{ result.officeName }} +
+ @if (result.status === 'STOP_PAYMENT') { +
+ {{ 'labels.inputs.Stop Reason' | translate }}{{ result.stopReason }} +
+ } + @if (result.status === 'LOST') { +
+ {{ 'labels.inputs.Lost Reason' | translate }}{{ result.lostReason }} +
+ } + @if (result.status === 'CANCELLED') { +
+ {{ 'labels.inputs.Cancelled Reason' | translate }}{{ result.cancelledReason }} +
+ } + @if (result.status === 'USED' && result.usedReason) { +
+ {{ 'labels.inputs.Used Reason' | translate }}{{ result.usedReason }} +
+ } +
+
+
+ } @empty { + @if (searched && !busy) { +
{{ 'labels.text.No records to display' | translate }}
+ } + } +
diff --git a/src/app/chequebook/leaf-lookup/leaf-lookup.component.scss b/src/app/chequebook/leaf-lookup/leaf-lookup.component.scss new file mode 100644 index 000000000..35b4d10b8 --- /dev/null +++ b/src/app/chequebook/leaf-lookup/leaf-lookup.component.scss @@ -0,0 +1,125 @@ +/** + * 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; + max-width: 700px; + margin: 0 auto; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.search-card { + margin-bottom: 16px; +} + +.search-row { + display: flex; + gap: 16px; + align-items: flex-start; + + mat-form-field { + flex: 1; + } +} + +.result-card { + margin-bottom: 16px; +} + +.result-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.series-tag { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + color: rgb(0 0 0 / 60%); +} + +.multi-match-note { + margin-bottom: 16px; + color: #a16207; + font-weight: 600; +} + +.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%); + } + + .warn-note { + grid-column: 1 / -1; + color: #a16207; + font-weight: 600; + } +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + + &.big { + font-size: 14px; + padding: 6px 14px; + border-radius: 8px; + } + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.used { + background: #e3edfd; + color: #1976d2; + } + + &.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%); +} diff --git a/src/app/chequebook/leaf-lookup/leaf-lookup.component.ts b/src/app/chequebook/leaf-lookup/leaf-lookup.component.ts new file mode 100644 index 000000000..74f7c8503 --- /dev/null +++ b/src/app/chequebook/leaf-lookup/leaf-lookup.component.ts @@ -0,0 +1,81 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ChequeBookService } from '../chequebook.service'; + +/** + * The teller/counter quick-lookup: given a bare cheque-leaf serial number, is it active, stopped, lost, or + * was it ever issued at all — one code path covers every case (issued or unissued-but-stolen stock). + */ +@Component({ + selector: 'mifosx-leaf-lookup', + templateUrl: './leaf-lookup.component.html', + styleUrls: ['./leaf-lookup.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class LeafLookupComponent { + private formBuilder = inject(UntypedFormBuilder); + private chequeBookService = inject(ChequeBookService); + private snackBar = inject(MatSnackBar); + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + searchForm: UntypedFormGroup; + // a bare serial number is only unique within its own series — more than one series can share a number, + // so the lookup can legitimately return more than one match + results: any[] = []; + searched = false; + busy = false; + + constructor() { + this.searchForm = this.formBuilder.group({ + serialNo: [ + '', + Validators.required + ] + }); + } + + search(): void { + if (this.searchForm.invalid || this.busy) { + return; + } + this.busy = true; + this.searched = true; + this.results = []; + this.chequeBookService.lookupLeaf(this.searchForm.value.serialNo).subscribe({ + next: (data: any) => { + this.busy = false; + this.results = Array.isArray(data) ? data : [data]; + }, + error: () => { + this.busy = false; + this.results = []; + this.notify('No cheque leaf found with that serial number', true); + } + }); + } +} diff --git a/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.html b/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.html new file mode 100644 index 000000000..9212aa877 --- /dev/null +++ b/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.html @@ -0,0 +1,47 @@ + + +

{{ 'labels.inputs.Report Remaining Stock Lost/Stolen' | translate }}

+
+
+
+ {{ 'labels.inputs.Series' | translate }} + {{ data.seriesCode }} +
+
+ {{ 'labels.inputs.Office' | translate }} + {{ data.officeName }} +
+
+ {{ 'labels.inputs.Remaining Range' | translate }} + {{ data.nextSerialNo }} – {{ data.rangeEnd }} +
+
+ +
+
+ + {{ 'labels.inputs.From Serial No' | translate }} + + {{ + 'labels.text.Only a suffix of the remaining undrawn range can be reported' | translate + }} + + + {{ 'labels.inputs.Reason' | translate }} + + +
+
+
+ + + + diff --git a/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.scss b/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.scss new file mode 100644 index 000000000..b5178e852 --- /dev/null +++ b/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.scss @@ -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; +} diff --git a/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.ts b/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.ts new file mode 100644 index 000000000..493922d83 --- /dev/null +++ b/src/app/chequebook/stock-management/report-lot-lost-dialog/report-lot-lost-dialog.component.ts @@ -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); + 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 + ); + } + }); + } +} diff --git a/src/app/chequebook/stock-management/stock-management.component.html b/src/app/chequebook/stock-management/stock-management.component.html new file mode 100644 index 000000000..0fe1e7026 --- /dev/null +++ b/src/app/chequebook/stock-management/stock-management.component.html @@ -0,0 +1,328 @@ + + +
+ + + + + +
+ + @if (stockSummary.length > 0) { +
+
{{ 'labels.inputs.Available Stock by Book Size' | translate }}
+
+ @for (row of stockSummary; track row.seriesCode + row.leavesPerBook) { +
+
{{ row.books }}
+
+ {{ 'labels.inputs.books' | translate }} × {{ row.leavesPerBook }}-{{ + 'labels.inputs.leaf' | translate + }} +
+
{{ row.seriesCode }}
+
+ } +
+
+ } + +
+ +
+ + @if (showReceiveForm) { + + +
+
+ + {{ 'labels.inputs.Series' | translate }} + + @for (s of seriesList; track s.id) { + {{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ + 'labels.inputs.leaf' | translate + }}) + } + + + + {{ 'labels.inputs.Office' | translate }} + + @for (office of offices; track office.id) { + {{ office.name }} + } + + + + {{ 'labels.inputs.Number of Books' | translate }} + + @if (receiveLeavesPerBook) { + {{ receiveTotalLeaves }} {{ 'labels.inputs.leaves' | translate }} ({{ + 'labels.inputs.books' | translate + }} + × {{ receiveLeavesPerBook }}) + } + + + {{ 'labels.inputs.Range Start' | translate }} + + {{ 'labels.text.Auto-suggested to avoid overlap; editable' | translate }} + + + {{ 'labels.inputs.Range End' | translate }} + + {{ 'labels.text.Calculated from books x book size' | translate }} + + + {{ 'labels.inputs.Batch / PO Reference' | translate }} + + +
+
+ + +
+
+
+
+ } + +
+ @for (s of lotStatusOptions; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Series' | translate }}{{ l.seriesCode }}{{ 'labels.inputs.Office' | translate }}{{ l.officeName }}{{ 'labels.inputs.Leaf Range' | translate }}{{ l.rangeStart }} – {{ l.rangeEnd }}{{ 'labels.inputs.Remaining' | translate }} + {{ l.remainingBooks ?? 0 }} {{ 'labels.inputs.books' | translate }} + × {{ l.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }} + ({{ l.remainingLeaves }} {{ 'labels.inputs.leaves' | translate }}) + @if (l.lowStock) { + {{ 'labels.text.Low Stock' | translate }} + } + {{ 'labels.inputs.Status' | translate }} + {{ l.status }} + + @if (l.status === 'AVAILABLE') { + + } +
+ @if (displayedLots.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (lotTotalRecords > 0) { + + } +
+
+
+ + + +
+
+ +
+ + @if (showDispatchForm) { + + +
+
+ + {{ 'labels.inputs.Series' | translate }} + + @for (s of seriesList; track s.id) { + {{ s.seriesCode }} — {{ s.name }} + } + + + + {{ 'labels.inputs.From Office' | translate }} + + @for (office of offices; track office.id) { + {{ office.name }} + } + + + + {{ 'labels.inputs.To Office' | translate }} + + @for (office of offices; track office.id) { + {{ office.name }} + } + + + + {{ 'labels.inputs.From Lot Id' | translate }} + + {{ + 'labels.text.The stock-lot id to draw the transfer from (see Stock Lots tab)' | translate + }} + + + {{ 'labels.inputs.No of Leaves' | translate }} + + +
+
+ + +
+
+
+
+ } + +
+ @for (s of transferStatusOptions; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Series' | translate }}{{ t.seriesCode }}{{ 'labels.inputs.From Office' | translate }}{{ t.fromOfficeName }}{{ 'labels.inputs.To Office' | translate }}{{ t.toOfficeName }}{{ 'labels.inputs.Leaf Range' | translate }}{{ t.rangeStart }} – {{ t.rangeEnd }}{{ 'labels.inputs.Leaves' | translate }}{{ t.totalLeaves }}{{ 'labels.inputs.Status' | translate }} + {{ t.status }} + {{ 'labels.inputs.Dispatch Date' | translate }}{{ t.dispatchDate | dateFormat }} + @if (t.status === 'DISPATCHED') { + + + } +
+ @if (stockTransfers.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (transferTotalRecords > 0) { + + } +
+
+
+
+
diff --git a/src/app/chequebook/stock-management/stock-management.component.scss b/src/app/chequebook/stock-management/stock-management.component.scss new file mode 100644 index 000000000..b27993936 --- /dev/null +++ b/src/app/chequebook/stock-management/stock-management.component.scss @@ -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%); +} diff --git a/src/app/chequebook/stock-management/stock-management.component.ts b/src/app/chequebook/stock-management/stock-management.component.ts new file mode 100644 index 000000000..e49ebf95f --- /dev/null +++ b/src/app/chequebook/stock-management/stock-management.component.ts @@ -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(); + 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) + }); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index ba32a35ee..25db734cf 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -359,6 +359,9 @@ Reconciliation + + Cheque Book + CKYC Mgmt @@ -495,6 +498,9 @@ Reconciliation + + Cheque Book + Documents Hub diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 05fcec760..ccc1888f7 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -234,6 +234,19 @@ {{ 'labels.menus.Main Items' | translate }} + + + + + {{ 'labels.menus.Cheque Book Inventory' | translate }} + + +
+ +
+
+ receipt_long +
+
Cheque Book Requests
+
+ Series, stock receipt and book requisitions awaiting a second user's approval. +
+
+
+
+ + +
+ + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && requests.length === 0) { +
+ +

No {{ filterStatus | lowercase }} requests

+

+ @if (filterStatus === 'PENDING') { + There are no pending cheque book requests waiting for review. + } @else { + No {{ filterStatus | lowercase }} cheque book requests found for this date range. + } +

+
+ } @else { +
+ + + + + + + + + + + + + @for (r of requests; track r.id) { + + + + + + + + + } + +
IdTypeMakerSubmitted AtStatusAction
#{{ r.id }} + {{ requestTypeLabels[r.requestType] || r.requestType }} + {{ r.makerName || r.makerUsername }}{{ formatDateTime(r.submittedOn) }} + {{ r.status }} + + +
+
+ + + } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.scss new file mode 100644 index 000000000..b172e5513 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.scss @@ -0,0 +1,374 @@ +/** + * 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/. + */ + +:host { + display: block; +} + +.wri-tab { + padding: 0; +} + +.wri-action-bar { + display: flex; + align-items: center; + padding: 14px 20px; + background: #fff; + border-bottom: 1px solid #e2e8f0; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 28px; + width: 28px; + height: 28px; + color: #0f4f3c; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + + &__sub { + font-size: 12px; + color: #64748b; + margin-top: 2px; + } +} + +.wri-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; +} + +.wri-fb-sep { + width: 1px; + height: 22px; + background: #e2e8f0; + flex-shrink: 0; + margin: 0 4px; +} + +.wri-filter-select { + height: 34px; + min-width: 130px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + cursor: pointer; + + &:focus { + outline: none; + border-color: #0f4f3c; + } +} + +.wri-presets { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.wri-preset-chip { + height: 28px; + padding: 0 12px; + border-radius: 14px; + border: 1px solid #e2e8f0; + background: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + color: #475569; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + border-color: #0f4f3c; + color: #0f4f3c; + background: #f0fdf4; + } + + &--active { + background: #0f4f3c; + border-color: #0f4f3c; + color: #fff; + + &:hover { + background: #0a3d2e; + } + } +} + +.wri-date-range { + display: inline-flex; + align-items: center; + gap: 4px; + + &__sep { + color: #94a3b8; + font-size: 16px; + font-weight: 300; + padding: 0 2px; + user-select: none; + } +} + +.wri-date-box { + display: inline-flex; + align-items: center; + gap: 4px; + height: 34px; + padding: 0 6px 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + transition: border-color 0.15s; + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #0f4f3c; + } + + &__lbl { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + white-space: nowrap; + user-select: none; + letter-spacing: 0.03em; + } + + &__input { + border: none; + outline: none; + font-size: 13px; + width: 76px; + color: #0d1b2a; + font-family: inherit; + background: transparent; + cursor: pointer; + } + + &__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + background: none; + cursor: pointer; + color: #94a3b8; + padding: 0; + border-radius: 4px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + color: #0f4f3c; + background: #f0fdf4; + } + } +} + +.wri-btn { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 7px; + font-family: inherit; + font-weight: 600; + cursor: pointer; + border: 1px solid #e2e8f0; + transition: background 0.15s; + height: 32px; + padding: 0 12px; + font-size: 13px; + background: #fff; + color: #0d1b2a; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &--refresh { + padding: 0 8px; + color: #64748b; + + &:hover:not(:disabled) { + background: #f1f5f9; + } + } + + &--review { + background: #0f4f3c; + color: #fff; + border-color: #0f4f3c; + + &:hover { + background: #0a3d2e; + } + } +} + +.wri-table-wrap { + overflow-x: auto; +} + +.wri-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid #f1f5f9; + } + + th { + font-size: 11px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; + background: #f8fafc; + white-space: nowrap; + } +} + +.wri-row { + cursor: pointer; + transition: background 0.1s; + + &:hover td { + background: #f0fdf4; + } +} + +.wri-th-id { + width: 60px; +} + +.wri-th-action { + width: 110px; + text-align: center; +} + +.wri-action-cell { + text-align: center; +} + +.wri-dt { + font-size: 12px; + color: #64748b; + white-space: nowrap; +} + +.wri-status { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; + + &--pending { + background: #fef3c7; + color: #92400e; + } + + &--approved { + background: #dcfce7; + color: #166534; + } + + &--rejected { + background: #fee2e2; + color: #991b1b; + } +} + +.cell-mono { + font-family: 'Courier New', monospace; + font-size: 12px; +} + +.wri-voucher { + font-weight: 700; + color: #1e40af; + letter-spacing: 0.06em; +} + +.wri-credit-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + background: #e0f2fe; + color: #0369a1; + font-size: 11px; + font-weight: 600; +} + +.empty-state { + text-align: center; + padding: 48px 24px; + color: #94a3b8; + + .empty-icon { + font-size: 40px; + color: #cbd5e1; + } + + .empty-title { + font-size: 16px; + font-weight: 600; + color: #64748b; + margin: 12px 0 4px; + } + + .empty-text { + font-size: 13px; + color: #94a3b8; + margin: 0; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.ts new file mode 100644 index 000000000..7859e3b45 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component.ts @@ -0,0 +1,222 @@ +/** + * 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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; + +/** Custom Services */ +import { ChequeBookService } from '../../../chequebook/chequebook.service'; +import { SettingsService } from '../../../settings/settings.service'; + +/** Dialog Components */ +import { ChequebookRequestDetailDialogComponent } from './request-detail-dialog/request-detail-dialog.component'; + +/** + * Bespoke maker-checker queue for cheque series / stock receipt / book requisition submissions — this + * CBS's own workflow (not Fineract's native command-approval engine, which bypasses maker-checker + * unconditionally for any role carrying ALL_FUNCTIONS, e.g. Super user). A DIFFERENT user than the maker + * must approve or reject each row here; the server enforces this even if the UI is bypassed. + */ +@Component({ + selector: 'mifosx-chequebook-requests', + templateUrl: './chequebook-requests.component.html', + styleUrls: ['./chequebook-requests.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatButtonModule, + MatPaginatorModule, + MatNativeDateModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule + ] +}) +export class ChequebookRequestsComponent implements OnInit { + private chequeBookService = inject(ChequeBookService); + private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); + + requests: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 20, + 50 + ]; + + filterStatus = 'PENDING'; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset = 'all'; + loading = false; + loaded = false; + + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: 'This Week' }, + { key: 'month', label: 'This Month' }, + { key: 'all', label: 'All' } + ]; + + get statusOptions(): { value: string; label: string }[] { + return [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + } + + readonly requestTypeLabels: Record = { + CREATE_SERIES: 'Add Cheque Series', + RECEIVE_STOCK: 'Receive Stock', + REQUEST_BOOK: 'Request Cheque Book', + DELETE_SERIES: 'Delete/Deactivate Cheque Series' + }; + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.applyPreset('all'); + } + + load(): void { + this.loading = true; + this.chequeBookService + .getPendingRequests( + this.filterStatus || undefined, + this.isoDate(this.fromDate), + this.isoDate(this.toDate), + this.pageIndex * this.pageSize, + this.pageSize + ) + .subscribe({ + next: (res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }, + error: () => { + this.requests = []; + this.totalRecords = 0; + this.loading = false; + this.loaded = true; + } + }); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) return undefined; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + onFilterChange(): void { + this.pageIndex = 0; + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.pageIndex = 0; + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.pageIndex = 0; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'week') { + const start = new Date(today); + start.setDate(today.getDate() - today.getDay()); + this.fromDate = start; + this.toDate = new Date(today); + } else if (key === 'month') { + this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + this.toDate = new Date(today); + } else { + this.fromDate = null; + this.toDate = null; + } + this.pageIndex = 0; + this.load(); + } + + review(request: any): void { + this.dialog + .open(ChequebookRequestDetailDialogComponent, { data: request, width: '640px' }) + .afterClosed() + .subscribe((result: any) => { + if (result?.actioned) { + this.load(); + } + }); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'wri-status--pending', + APPROVED: 'wri-status--approved', + REJECTED: 'wri-status--rejected' + }; + return map[status] ?? ''; + } + + formatDateTime(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.html new file mode 100644 index 000000000..9fc95f1e2 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.html @@ -0,0 +1,73 @@ + + +
+ #{{ data.id }} + {{ requestTypeLabels[data.requestType] || data.requestType }} + + {{ data.makerName || data.makerUsername }} + {{ formatDateTime(data.submittedOn) }} + {{ data.status }} + +
+ +
+
+ @for (f of payloadEntries; track f.label) { +
+ {{ f.label }} + {{ f.value }} +
+ } +
+ + @if (data.status === 'REJECTED' && data.rejectionReason) { +
+ Rejection Reason + {{ data.rejectionReason }} +
+ } + + @if (activityEntries.length > 0) { +
+
Activity Log
+ @for (a of activityEntries; track a.timestamp) { +
+ {{ a.action }} + {{ a.user }} + {{ formatDateTime(a.timestamp) }} + @if (a.note) { + — {{ a.note }} + } +
+ } +
+ } +
+ +@if (data.status === 'PENDING') { +
+ + +
+ + @if (rejecting) { +
+ + Rejection Reason + + + +
+ } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.scss new file mode 100644 index 000000000..43ab17a24 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.scss @@ -0,0 +1,178 @@ +/** + * 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/. + */ + +.rdd-title { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin: 0 !important; +} + +.rdd-id { + font-weight: 700; + color: #1e40af; +} + +.rdd-type-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + background: #e0f2fe; + color: #0369a1; + font-size: 12px; + font-weight: 700; +} + +.rdd-spacer { + flex: 1; +} + +.rdd-maker { + font-size: 14px; + color: #0d1b2a; +} + +.rdd-date { + font-size: 12px; + color: #64748b; +} + +.rdd-status { + display: inline-flex; + align-items: center; + padding: 3px 12px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; + + &--pending { + background: #fef3c7; + color: #92400e; + } + + &--approved { + background: #dcfce7; + color: #166534; + } + + &--rejected { + background: #fee2e2; + color: #991b1b; + } +} + +.rdd-close-btn { + background: #0f4f3c !important; + color: #fff !important; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 2px; + } +} + +.rdd-content { + min-width: 560px; + padding-top: 8px !important; +} + +.rdd-payload { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 14px 24px; + + .rdd-field { + display: flex; + flex-direction: column; + } + + .k { + font-size: 0.7rem; + text-transform: uppercase; + color: #94a3b8; + letter-spacing: 0.04em; + } +} + +.rdd-reject-reason { + display: flex; + flex-direction: column; + margin-top: 16px; + + .k { + font-size: 0.7rem; + text-transform: uppercase; + color: #94a3b8; + letter-spacing: 0.04em; + } +} + +.rdd-activity { + margin-top: 20px; + + .k { + font-size: 0.7rem; + text-transform: uppercase; + color: #94a3b8; + letter-spacing: 0.04em; + margin-bottom: 8px; + } +} + +.rdd-activity-entry { + display: flex; + gap: 10px; + font-size: 0.85rem; + padding: 5px 0; + border-bottom: 1px dashed #e2e8f0; + + &:last-child { + border-bottom: none; + } +} + +.rdd-activity-action { + font-weight: 600; + color: #0d1b2a; +} + +.rdd-activity-user { + color: #64748b; +} + +.rdd-activity-time { + color: #94a3b8; +} + +.rdd-activity-note { + color: #64748b; + font-style: italic; +} + +.rdd-actions { + display: flex; + gap: 12px; +} + +.rdd-reject-form { + display: flex; + align-items: center; + gap: 12px; + padding: 0 24px 16px; + + mat-form-field { + flex: 1; + max-width: 360px; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.ts new file mode 100644 index 000000000..513e86ffb --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/chequebook-requests/request-detail-dialog/request-detail-dialog.component.ts @@ -0,0 +1,170 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ChequeBookService } from '../../../../chequebook/chequebook.service'; + +/** Popup shown when a Cheque Book Request row's "Review" button is clicked. */ +@Component({ + selector: 'mifosx-chequebook-request-detail-dialog', + templateUrl: './request-detail-dialog.component.html', + styleUrls: ['./request-detail-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatIconModule, + MatButtonModule + ] +}) +export class ChequebookRequestDetailDialogComponent { + dialogRef = inject>(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; + rejecting = false; + rejectForm: UntypedFormGroup; + + readonly requestTypeLabels: Record = { + CREATE_SERIES: 'Add Cheque Series', + RECEIVE_STOCK: 'Receive Stock', + REQUEST_BOOK: 'Request Cheque Book', + DELETE_SERIES: 'Delete/Deactivate Cheque Series' + }; + + readonly fieldLabels: Record = { + seriesCode: 'Series Code', + name: 'Name', + leavesPerBook: 'Leaves Per Book', + applicableAccountType: 'Applicable Account Type', + ctsCompliant: 'CTS-2010 Compliant', + reorderLevel: 'Reorder Level', + seriesId: 'Series', + officeId: 'Office', + rangeStart: 'Range Start', + rangeEnd: 'Range End', + batchRef: 'Batch Reference', + accountType: 'Account Type', + accountId: 'Account', + noOfBooks: 'Number of Books' + }; + + constructor() { + this.rejectForm = this.formBuilder.group({ + reason: [ + '', + Validators.required + ] + }); + } + + get payloadEntries(): { label: string; value: any }[] { + try { + const payload = JSON.parse(this.data.payloadJson || '{}'); + return Object.keys(payload) + .filter( + (key) => ![ + 'locale', + 'dateFormat' + ].includes(key) + ) + .map((key) => ({ label: this.fieldLabels[key] || key, value: payload[key] })); + } catch { + return []; + } + } + + get activityEntries(): { timestamp: string; action: string; user: string; note?: string }[] { + try { + return JSON.parse(this.data.activityLog || '[]'); + } catch { + return []; + } + } + + approve(): void { + if (this.busy) return; + this.busy = true; + this.chequeBookService.approvePendingRequest(this.data.id).subscribe({ + next: () => { + this.busy = false; + this.notify('Request approved'); + this.dialogRef.close({ actioned: true }); + }, + error: (err) => { + this.busy = false; + this.notify(err?.error?.defaultUserMessage || 'Approve failed', true); + } + }); + } + + startReject(): void { + this.rejecting = !this.rejecting; + this.rejectForm.reset(); + } + + submitReject(): void { + if (this.rejectForm.invalid || this.busy) return; + this.busy = true; + const payload = { reason: this.rejectForm.value.reason, locale: 'en' }; + this.chequeBookService.rejectPendingRequest(this.data.id, payload).subscribe({ + next: () => { + this.busy = false; + this.notify('Request rejected'); + this.dialogRef.close({ actioned: true }); + }, + error: (err) => { + this.busy = false; + this.notify(err?.error?.defaultUserMessage || 'Reject failed', true); + } + }); + } + + formatDateTime(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 1b6c575b1..7a073ade3 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -166,6 +166,18 @@ Disbursement & Transfers + + + Cheque Book Requests + diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 273019314..b4050cc5f 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -27,6 +27,7 @@ import { PendingAccountComponent } from './checker-inbox-and-tasks-tabs/pending- import { RejectedAccountComponent } from './checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component'; import { TerminatedAccountComponent } from './checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component'; import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component'; +import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component'; import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component'; /** Custom Resolvers */ @@ -139,6 +140,11 @@ const routes: Routes = [ path: 'disbursement-requests', component: DisbursementRequestsComponent, data: { title: 'Disbursement Requests' } + }, + { + path: 'chequebook-requests', + component: ChequebookRequestsComponent, + data: { title: 'Cheque Book Requests' } } ] }, diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index f374ad4a2..b40b402a1 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -351,6 +351,17 @@ "buttons": { "Accept Transfer": "Accept Transfer", "Actions": "Actions", + "Add Cheque Series": "Add Cheque Series", + "Receive Stock": "Receive Stock", + "Report Lost": "Report Lost", + "Dispatch Transfer": "Dispatch Transfer", + "Receive": "Receive", + "Request Cheque Book": "Request Cheque Book", + "Issue": "Issue", + "Stop Payment": "Stop Payment", + "Revoke Stop Payment": "Revoke Stop Payment", + "Cancel Leaf": "Cancel Leaf", + "Mark as Used": "Mark as Used", "Activate": "Activate", "Activate Self Service User": "Activate Self Service User", "Add": "Add", @@ -1462,6 +1473,58 @@ "inputs": { "# Remittance": "# Remittance", "# Senders": "# Senders", + "Client": "Client", + "Series": "Series", + "Detail": "Detail", + "Cheque Series": "Cheque Series", + "Series Code": "Series Code", + "Leaves Per Book": "Leaves Per Book", + "Reorder Level": "Reorder Level", + "CTS-2010 Compliant": "CTS-2010 Compliant", + "Applicable Account Type": "Applicable Account Type", + "Both (Savings + Current)": "Both (Savings + Current)", + "Savings Only": "Savings Only", + "Current Only": "Current Only", + "Cheque Stock": "Cheque Stock", + "Stock Lots": "Stock Lots", + "Stock Transfers": "Stock Transfers", + "Range Start": "Range Start", + "Range End": "Range End", + "Batch / PO Reference": "Batch / PO Reference", + "Leaf Range": "Leaf Range", + "Remaining Range": "Remaining Range", + "Remaining": "Remaining", + "books": "books", + "leaf": "leaf", + "leaves": "leaves", + "Available Stock by Book Size": "Available Stock by Book Size", + "From Office": "From Office", + "From Lot Id": "From Lot Id", + "No of Leaves": "No of Leaves", + "Dispatch Date": "Dispatch Date", + "Cheque Books": "Cheque Books", + "Account Id": "Account Id", + "No of Leaves Requested": "No of Leaves Requested", + "Number of Books": "Number of Books", + "Quantity": "Quantity", + "Requested": "Requested", + "books available": "books available", + "Leaves": "Leaves", + "Requested Date": "Requested Date", + "Savings": "Savings", + "Current": "Current", + "Cheque Book": "Cheque Book", + "From Leaf Seq": "From Leaf Seq", + "To Leaf Seq": "To Leaf Seq", + "Seq No": "Seq No", + "Serial No": "Serial No", + "Leaf Lookup": "Leaf Lookup", + "Cheque Leaf Serial No": "Cheque Leaf Serial No", + "Stop Reason": "Stop Reason", + "Lost Reason": "Lost Reason", + "Cancelled Reason": "Cancelled Reason", + "Used Reason": "Used Reason", + "Issue Date": "Issue Date", "ACCOUNTING": "ACCOUNTING", "ADDRESS": "ADDRESS", "Aadhaar Number": "Aadhaar Number", @@ -3104,6 +3167,7 @@ "Modify Application": "Modify Application", "More": "More", "Analytics": "Analytics", + "Cheque Book Inventory": "Cheque Book Inventory", "RBI Circulars": "RBI Circulars & Notifications", "CFCFRMS": "CFCFRMS", "CFCFRMS Dashboard": "CFCFRMS Dashboard", @@ -3236,6 +3300,22 @@ }, "text": { "A": "A", + "No records to display": "No records to display", + "Low-stock alert threshold (remaining leaves)": "Low-stock alert threshold (remaining leaves)", + "Low Stock": "Low Stock", + "Only a suffix of the remaining undrawn range can be reported": "Only a suffix of the remaining undrawn range can be reported", + "The stock-lot id to draw the transfer from (see Stock Lots tab)": "The stock-lot id to draw the transfer from (see Stock Lots tab)", + "Leave blank for the whole book": "Leave blank for the whole book", + "Optional — e.g. cleared per bank statement dated ...": "Optional — e.g. cleared per bank statement dated ...", + "This leaf was never issued to a customer — reported lost/stolen from stock.": "This leaf was never issued to a customer — reported lost/stolen from stock.", + "More than one series has a leaf with this serial number — check the series code against the physical cheque.": "More than one series has a leaf with this serial number — check the series code against the physical cheque.", + "SB and CA cheque stationery differ": "SB and CA cheque stationery differ — pick which scheme this series is for", + "Only series applicable to this account type are shown": "Only series applicable to this account type are shown", + "Leaves handed over = books x the series book size": "Leaves handed over = books × the series book size", + "Auto-suggested to avoid overlap; editable": "Auto-suggested to avoid overlap; editable", + "Calculated from books x book size": "Calculated from books × book size", + "Searching...": "Searching...", + "Type at least 4 digits of the account number": "Type at least 4 digits of the account number", "Ability to manage holidays for individual offices": "The ability to manage holidays for individual offices is a very useful tool for an organization spanning multiple locations.Use this option to customize holidays for each office of your organization.", "About Us": "About Us", "Account Detail": "Account Detail", @@ -4407,6 +4487,7 @@ "Add": "Add", "Add Filters": "Add Filters", "Analytics": "Analytics Dashboard (Apache Superset)", + "Cheque Book Inventory": "Cheque Book Inventory — stock, issuance, stop-payment, hotlisting", "RBI Circulars": "RBI Circulars & Notifications", "CFCFRMS Dashboard": "CFCFRMS Cyber Fraud Complaint Dashboard", "Complaints": "Complaint Ticket Management",