From ab8ed7e67e052e3ddae36214e3acaf4ab1d8cc42 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:28:27 +0530 Subject: [PATCH] feat(vouchers): voucher correction screens and a correction status on the voucher registers A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until the final approval. - Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the request and deposits separately. - Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are added to the same tab bar. - A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup) says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason. - The deposit taken off the wrong account no longer shows on its passbook or transaction list. Co-Authored-By: Claude Sonnet 5.5 --- src/app/cashier/cashier-routing.module.ts | 13 + .../cashier/day-book/day-book.component.html | 15 +- .../cashier/day-book/day-book.component.ts | 9 +- .../cashier/my-work/my-work.component.html | 13 + src/app/cashier/my-work/my-work.component.ts | 9 +- .../voucher-detail-dialog.component.ts | 18 +- ...oucher-correction-approvals.component.html | 170 +++++ ...oucher-correction-approvals.component.scss | 485 ++++++++++++++ .../voucher-correction-approvals.component.ts | 192 ++++++ src/app/cashier/voucher-correction.service.ts | 120 ++++ .../voucher-correction.component.html | 446 +++++++++++++ .../voucher-correction.component.scss | 599 ++++++++++++++++++ .../voucher-correction.component.ts | 307 +++++++++ .../voucher-report.component.html | 13 + .../voucher-report.component.scss | 5 + .../voucher-report.component.ts | 9 +- .../voucher-status.component.ts | 137 ++++ src/app/core/shell/shell.component.html | 18 + .../savings-account-view.resolver.ts | 23 +- .../checker-inbox-and-tasks.component.html | 18 + src/app/tasks/tasks-routing.module.ts | 16 + 21 files changed, 2626 insertions(+), 9 deletions(-) create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts create mode 100644 src/app/cashier/voucher-correction.service.ts create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.html create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.scss create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.ts create mode 100644 src/app/cashier/voucher-status/voucher-status.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 76947f25b..f4c7d8401 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -83,6 +83,13 @@ const routes: Routes = [ ), data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' } }, + { + // The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here. + path: 'voucher-correction', + loadComponent: () => + import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent), + data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' } + }, { path: 'closure-requests', loadComponent: () => @@ -291,6 +298,12 @@ const routes: Routes = [ component: ReconciliationComponent, data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' } }, + { + path: 'voucher-correction', + loadComponent: () => + import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent), + data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' } + }, { path: 'cash-history', component: CashHistoryComponent, diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html index 5c5215145..116009136 100644 --- a/src/app/cashier/day-book/day-book.component.html +++ b/src/app/cashier/day-book/day-book.component.html @@ -202,6 +202,7 @@ Amount Payment Status + Correction By Approved By Time @@ -231,6 +232,18 @@ {{ r.status }} + + + + @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') { + — + } + {{ r.handledBy ?? '—' }} {{ r.approvedBy ?? '—' }} @@ -256,7 +269,7 @@ — } - + diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 05fb8e475..59bac8801 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -14,6 +14,7 @@ import { Router } from '@angular/router'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; /** * Which part of the day's work a row came from. Set by the server, one per source register. @@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM'; interface DayBookRow { voucherNo: string | null; + /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */ + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; section: DayBookSection; side: 'CR' | 'DR'; kind: string; @@ -58,7 +64,8 @@ interface DayBookRow { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ] }) export class DayBookComponent implements OnInit { diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html index e4c96e7db..e62edb863 100644 --- a/src/app/cashier/my-work/my-work.component.html +++ b/src/app/cashier/my-work/my-work.component.html @@ -146,6 +146,7 @@ Amount My part Status + Correction Time @@ -203,6 +204,18 @@ {{ statusLabel(r.verificationStatus) }} + + + + @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') { + — + } + {{ r.createdAt | date: 'HH:mm' }} } diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 8c0cf4bd4..28ef84cc3 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component'; import { businessToday } from 'app/core/utils/business-today'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; /** What the signed-in user's part in a voucher was. */ export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; @@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV'; interface MyWorkRow { voucherNo: string; + /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */ + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; side: VoucherSide | null; voucherType: string | null; amount: number | null; @@ -76,7 +82,8 @@ interface MyWorkReport { CommonModule, FormsModule, MatIconModule, - MatDialogModule + MatDialogModule, + VoucherStatusComponent ] }) export class MyWorkComponent implements OnInit { diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts index 93137c3c7..a50339751 100644 --- a/src/app/cashier/my-work/voucher-detail-dialog.component.ts +++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts @@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; export interface VoucherDetailDialogData { /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog @@ -18,6 +19,10 @@ export interface VoucherDetailDialogData { * duplicated inside every dialog that could ever show a voucher. */ detail: { voucherNo: string; + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; voucherType: string | null; amount: number | null; branchName: string | null; @@ -58,7 +63,8 @@ export interface VoucherDetailDialogData { CommonModule, MatDialogModule, MatButtonModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ], template: `
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
receipt_long
-

{{ detail.voucherNo }}

+

+ {{ detail.voucherNo }} + +

{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}

diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html new file mode 100644 index 000000000..f65551611 --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html @@ -0,0 +1,170 @@ + + +
+ +
+
+ undo +
+
Voucher Corrections
+
+ Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval + moves money. +
+
+
+
+ {{ unpassCount }} unpass + {{ correctionCount }} correction +
+
+ + +
+ + + + + + + +
+ + + @if (loading && !loaded) { +

Loading…

+ } @else if (rows.length === 0) { +
+ inbox +

No voucher correction waiting

+

There is no unpass request or correction entry waiting for review.

+
+ } @else { +
+ + + + + + + + + + + + + + + + @for (r of rows; track r.request.id) { + + + + + + + + + + + + } + +
Voucher NoRequestPosted To (Wrong)Correct AccountAmountReasonRaised ByRaised AtAction
{{ r.request.original_voucher_no }} + {{ kindLabel(r.kind) }} + +
{{ r.request.wrong_account_no }}
+
{{ r.request.wrong_client_name || '—' }}
+
+ @if (r.kind === 'correction') { +
{{ r.request.correct_account_no }}
+
{{ r.request.correct_client_name || '—' }}
+ } @else { + — + } +
₹{{ r.request.amount | number: '1.2-2' }}{{ r.request.reason || '—' }}{{ r.raisedBy || '—' }}{{ formatDateTime(r.raisedAt) }} + + +
+
+ } +
+ + + + @if (rejecting) { +
+
+ block +
+
+ Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }} +
+
{{ rejecting.request.original_voucher_no }}
+
+ +
+
+ + +
+
+ + +
+
+ } +
diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss new file mode 100644 index 000000000..c8fbb6097 --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss @@ -0,0 +1,485 @@ +/** + * 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/. + */ + +// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table. +:host { + display: block; +} + +.vca-tab { + padding: 0; +} + +.vca-action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 16px; + background: #fff; + border-bottom: 1px solid #e2e8f0; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #0f4f3c; + } + + &__title { + font-size: 13px; + font-weight: 700; + color: #0d1b2a; + } + + &__sub { + max-width: 640px; + margin-top: 1px; + font-size: 11px; + color: #64748b; + } +} + +.vca-counts { + display: flex; + gap: 8px; +} + +.vca-count { + padding: 2px 10px; + border-radius: 999px; + background: #f1f5f9; + font-size: 11px; + color: #475569; + + strong { + color: #0f4f3c; + } +} + +.vca-filter-bar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 6px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.vca-fb-sep { + flex-shrink: 0; + width: 1px; + height: 22px; + margin: 0 4px; + background: #e2e8f0; +} + +.vca-search-box { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + min-width: 240px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + + mat-icon { + flex-shrink: 0; + font-size: 17px; + width: 17px; + height: 17px; + color: #94a3b8; + } + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #0f4f3c; + box-shadow: 0 0 0 3px rgb(15 79 60 / 8%); + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-family: inherit; + font-size: 12px; + color: #0d1b2a; + + &::placeholder { + color: #94a3b8; + } + } + + &__clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: none; + color: #94a3b8; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: inherit; + } + + &:hover { + color: #dc2626; + background: #fef2f2; + } + } +} + +.vca-filter-select { + height: 30px; + min-width: 140px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + background: #fff; + font-family: inherit; + font-size: 12px; + color: #0d1b2a; + cursor: pointer; + + &:focus { + outline: none; + border-color: #0f4f3c; + } +} + +// ── Buttons ───────────────────────────────────────────────────────────────── +.vca-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 7px; + font-family: inherit; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &--refresh { + height: 30px; + width: 30px; + justify-content: center; + padding: 0; + margin-left: auto; + border-color: #e2e8f0; + background: #fff; + color: #475569; + } + + &--approve { + background: #0f4f3c; + color: #fff; + + &:hover:not(:disabled) { + background: #0a3d2e; + } + } + + &--reject { + border-color: #fecaca; + background: #fff; + color: #dc2626; + + &:hover:not(:disabled) { + background: #fef2f2; + } + } + + &--reject-solid { + background: #dc2626; + color: #fff; + } + + &--ghost { + border-color: #e2e8f0; + background: #fff; + color: #475569; + } +} + +// ── Table ─────────────────────────────────────────────────────────────────── +.vca-table-wrap { + overflow-x: auto; + background: #fff; +} + +.vca-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + + th { + padding: 7px 10px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; + color: #94a3b8; + white-space: nowrap; + } + + td { + padding: 5px 10px; + border-bottom: 1px solid #eef2f7; + vertical-align: middle; + color: #0d1b2a; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .vca-th-action { + text-align: right; + } +} + +.vca-row:hover td { + background: #f8fafc; +} + +.vca-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +.vca-voucher { + font-weight: 700; + color: #0f4f3c; +} + +.vca-strong { + font-weight: 700; +} + +.vca-sub { + margin-top: 1px; + font-size: 11px; + color: #64748b; +} + +.vca-none { + color: #cbd5e1; +} + +.vca-amount { + font-weight: 700; +} + +.vca-reason { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vca-dt { + white-space: nowrap; + color: #475569; +} + +.vca-kind { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + + &--unpass { + background: #fef3c7; + color: #92400e; + } + + &--correction { + background: #dbeafe; + color: #1e40af; + } +} + +.vca-action-cell { + display: flex; + justify-content: flex-end; + gap: 6px; + white-space: nowrap; +} + +// ── Empty ─────────────────────────────────────────────────────────────────── +.vca-empty { + padding: 28px 16px; + text-align: center; + color: #94a3b8; + + &__icon { + display: block; + margin: 0 auto 6px; + font-size: 30px; + width: 30px; + height: 30px; + opacity: 0.6; + } + + &__title { + margin: 0; + font-size: 12px; + font-weight: 700; + color: #64748b; + } + + &__text { + margin: 3px 0 0; + font-size: 11px; + } +} + +// ── Reject popup ──────────────────────────────────────────────────────────── +.vca-dlg { + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + } + + &__ico { + flex: 0 0 32px; + height: 32px; + width: 32px; + border-radius: 9px; + background: #fef2f2; + color: #dc2626; + font-size: 18px; + line-height: 32px; + text-align: center; + } + + &__titles { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__x { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + cursor: pointer; + + &:hover { + background: #e2e8f0; + } + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + } + + &__body { + padding: 12px 14px; + + textarea { + width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + font-size: 12px; + resize: vertical; + + &:focus { + outline: 0; + border-color: #0f4f3c; + box-shadow: 0 0 0 3px rgb(15 79 60 / 8%); + } + } + } + + &__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.vca-label { + display: block; + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #94a3b8; +} diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts new file mode 100644 index 000000000..56fe1650c --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts @@ -0,0 +1,192 @@ +/** + * 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/. + */ + +import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service'; + +type Kind = 'unpass' | 'correction'; +type KindFilter = 'ALL' | Kind; + +/** One row of the approvals table: a request, and which of the two steps it is waiting at. */ +interface ApprovalRow { + kind: Kind; + request: VoucherCorrection; + raisedBy: string | null; + raisedAt: string | null; +} + +/** + * Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a + * voucher, and later the correction entry the cashier made. Only the second approval moves money. + */ +@Component({ + selector: 'mifosx-voucher-correction-approvals', + templateUrl: './voucher-correction-approvals.component.html', + styleUrls: ['./voucher-correction-approvals.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatIconModule, + MatSnackBarModule + ] +}) +export class VoucherCorrectionApprovalsComponent implements OnInit { + private service = inject(VoucherCorrectionService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + @ViewChild('rejectDialog') rejectDialog!: TemplateRef; + private dialogRef: MatDialogRef | null = null; + + all: ApprovalRow[] = []; + loading = false; + loaded = false; + busyId: number | null = null; + + kindFilter: KindFilter = 'ALL'; + searchTerm = ''; + + /** The row being rejected, and the reason typed for it. */ + rejecting: ApprovalRow | null = null; + rejectReason = ''; + + readonly kindOptions: { value: KindFilter; label: string }[] = [ + { value: 'ALL', label: 'All requests' }, + { value: 'unpass', label: 'Unpass requests' }, + { value: 'correction', label: 'Correction entries' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .list('UNPASS_PENDING,CORRECTION_PENDING') + .pipe(catchError(() => of([] as VoucherCorrection[]))) + .subscribe((rows) => { + this.all = rows.map((r) => + r.status === 'UNPASS_PENDING' + ? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at } + : { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at } + ); + this.loading = false; + this.loaded = true; + }); + } + + /** The table: the kind picked, narrowed by whatever is typed in the search box. */ + get rows(): ApprovalRow[] { + const term = this.searchTerm.trim().toLowerCase(); + return this.all.filter((r) => { + if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false; + if (!term) return true; + const q = r.request; + return [ + q.original_voucher_no, + q.wrong_account_no, + q.wrong_client_name, + q.correct_account_no, + q.correct_client_name, + r.raisedBy + ].some((v) => (v ?? '').toLowerCase().includes(term)); + }); + } + + get unpassCount(): number { + return this.all.filter((r) => r.kind === 'unpass').length; + } + + get correctionCount(): number { + return this.all.filter((r) => r.kind === 'correction').length; + } + + kindLabel(kind: Kind): string { + return kind === 'unpass' ? 'Unpass' : 'Correction'; + } + + formatDateTime(value: string | null): string { + if (!value) return '—'; + const d = new Date(value); + return isNaN(d.getTime()) + ? value + : d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); + } + + approve(row: ApprovalRow): void { + if (this.busyId) return; + const id = row.request.id; + this.busyId = id; + const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id); + call.subscribe({ + next: (res: any) => { + this.busyId = null; + this.snackBar.open( + row.kind === 'unpass' + ? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account` + : `Corrected. New voucher ${res?.newVoucherNo ?? ''}`, + 'Close', + { duration: 6000 } + ); + this.load(); + }, + error: (err) => { + this.busyId = null; + this.fail(err, 'Could not approve'); + } + }); + } + + startReject(row: ApprovalRow): void { + this.rejecting = row; + this.rejectReason = ''; + this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false }); + this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null)); + } + + cancelReject(): void { + this.dialogRef?.close(); + } + + confirmReject(): void { + if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return; + const row = this.rejecting; + const id = row.request.id; + this.busyId = id; + const reason = this.rejectReason.trim(); + const call = + row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason); + call.subscribe({ + next: () => { + this.busyId = null; + this.dialogRef?.close(); + this.snackBar.open('Rejected', 'Close', { duration: 4000 }); + this.load(); + }, + error: (err) => { + this.busyId = null; + this.fail(err, 'Could not reject'); + } + }); + } + + private fail(err: any, fallback: string): void { + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; + this.snackBar.open(msg, 'Close', { duration: 8000 }); + } +} diff --git a/src/app/cashier/voucher-correction.service.ts b/src/app/cashier/voucher-correction.service.ts new file mode 100644 index 000000000..d252a561a --- /dev/null +++ b/src/app/cashier/voucher-correction.service.ts @@ -0,0 +1,120 @@ +/** + * 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/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** One row of a voucher correction, as the server keeps it (snake_case columns). */ +export interface VoucherCorrection { + id: number; + original_voucher_no: string; + original_txn_id: number; + wrong_savings_account_id: number; + wrong_account_no: string; + wrong_client_name: string | null; + amount: number; + txn_date: string | null; + status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED'; + reason: string | null; + rejection_reason: string | null; + unpass_by_name: string | null; + unpass_at: string | null; + unpass_approved_by_name: string | null; + correct_account_no: string | null; + correct_client_name: string | null; + correction_by_name: string | null; + correction_at: string | null; + correction_approved_by_name: string | null; + new_voucher_no: string | null; +} + +/** A cash deposit voucher of today that can still be unpassed. */ +export interface EligibleVoucher { + voucherNo: string; + amount: number; + createdAt: string; + cashier: string; + txnId: number; + savingsAccountId: number; + accountNo: string; + clientName: string | null; + paymentType: string; +} + +/** What the server knows about a savings account typed in as the correct one. */ +export interface AccountLookup { + id: number; + accountNo: string; + clientId: number | null; + clientName: string | null; + balance: number; + currencyCode: string; + canReceive: boolean; +} + +/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */ +@Injectable({ providedIn: 'root' }) +export class VoucherCorrectionService { + private http = inject(HttpClient); + private readonly base = '/vouchers/corrections'; + + eligible(search?: string): Observable { + let params = new HttpParams(); + if (search) params = params.set('search', search); + return this.http.get(`${this.base}/eligible`, { params }); + } + + unpass(voucherNo: string, reason: string): Observable { + return this.http.post(`${this.base}/unpass`, { voucherNo, reason }); + } + + list(status?: string, mine = false): Observable { + let params = new HttpParams().set('mine', String(mine)); + if (status) params = params.set('status', status); + return this.http.get(this.base, { params }); + } + + get(id: number): Observable { + return this.http.get(`${this.base}/${id}`); + } + + accountLookup(accountNo: string): Observable { + return this.http.get(`${this.base}/account-lookup`, { params: { accountNo } }); + } + + submitCorrection(id: number, correctAccountNo: string): Observable { + return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo }); + } + + /** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */ + close(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/close`, { reason }); + } + + approveUnpass(id: number): Observable { + return this.http.post(`${this.base}/${id}/unpass/approve`, {}); + } + + rejectUnpass(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/unpass/reject`, { reason }); + } + + approveCorrection(id: number): Observable { + return this.http.post(`${this.base}/${id}/correction/approve`, {}); + } + + rejectCorrection(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/correction/reject`, { reason }); + } + + /** Ids of the transactions of this account that a completed correction removed. */ + voidedTransactions(savingsAccountId: number): Observable { + return this.http.get(`${this.base}/voided-transactions`, { params: { savingsAccountId } }); + } +} diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.html b/src/app/cashier/voucher-correction/voucher-correction.component.html new file mode 100644 index 000000000..6c113b6d2 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.html @@ -0,0 +1,446 @@ + + +
+
+
+
CASHIER · VOUCHER CORRECTION
+

Voucher Correction

+

+ Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final + approval. +

+
+
+ + +
    +
  1. + 1 + Unpass the voucher +
  2. +
  3. + 2 + Manager approves +
  4. +
  5. + 3 + Enter the correct account +
  6. +
  7. + 4 + Manager approves, deposit moves +
  8. +
+ + + + + @if (tab === 'today') { +
+ + + +
+ +
+ + + + + + + + + + + + + + @for (v of vouchers; track v.voucherNo) { + + + + + + + + + + } + @if (!vouchers.length) { + + + + } + +
VoucherAccountCustomerModeCashierAmount
+ {{ v.voucherNo }} + {{ v.accountNo }}{{ v.clientName || '—' }}{{ v.paymentType }}{{ v.cashier }}₹{{ v.amount | number: '1.2-2' }} + +
+ {{ loadingVouchers ? 'hourglass_empty' : 'inbox' }} +
{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}
+
+
+ } + + + @if (tab === 'unpassed') { +
+ + + + + + + + + + + + + @for (u of unpassed; track u.id) { + + + + + + + + + } + @if (!unpassed.length) { + + + + } + +
VoucherPosted toCustomerReasonAmount
+ {{ u.original_voucher_no }} + {{ u.wrong_account_no }}{{ u.wrong_client_name || '—' }}{{ u.reason }}₹{{ u.amount | number: '1.2-2' }} + +
+ {{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }} +
+ {{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }} +
+
+
+ } + + + @if (tab === 'mine') { +
+ + + + + + + + + + + + + @for (m of mine; track m.id) { + + + + + + + + + } + @if (!mine.length) { + + + + } + +
VoucherPosted toCorrected toAmountStatusNew voucher
+ {{ m.original_voucher_no }} + {{ m.wrong_account_no }}{{ m.correct_account_no || '—' }}₹{{ m.amount | number: '1.2-2' }} + {{ statusLabel[m.status] }} + @if (m.rejection_reason) { +
{{ m.rejection_reason }}
+ } +
{{ m.new_voucher_no || '—' }}
+ {{ loadingMine ? 'hourglass_empty' : 'history' }} +
{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}
+
+
+ } +
+ + + + @if (selectedVoucher) { +
+
+ undo +
+
Unpass voucher
+
{{ selectedVoucher.voucherNo }}
+
+ +
+ +
+
+ Deposit amount + ₹{{ selectedVoucher.amount | number: '1.2-2' }} +
+ +
+
+
Voucher
+
{{ selectedVoucher.voucherNo }}
+
+
+
Created
+
{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}
+
+
+
Cashier
+
{{ selectedVoucher.cashier }}
+
+
+
Mode
+
{{ selectedVoucher.paymentType }}
+
+
+
Account
+
{{ selectedVoucher.accountNo }}
+
+
+
Customer
+
{{ selectedVoucher.clientName || '—' }}
+
+
+
Account balance now
+
+ @if (voucherAccount) { + ₹{{ voucherAccount.balance | number: '1.2-2' }} + } @else { + — + } +
+
+
+
Account status
+
+ @if (voucherAccount) { + {{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }} + } @else { + — + } +
+
+
+ + + + +
+ info + A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the + deposit stays on {{ selectedVoucher.accountNo }} till then. +
+
+ +
+ + +
+
+ } +
+ + + + @if (active) { +
+
+ edit_note +
+
Correction entry
+
{{ active.original_voucher_no }}
+
+ +
+ +
+
+ Amount to move + ₹{{ active.amount | number: '1.2-2' }} +
+ + +
+
+
Transaction date
+
{{ active.txn_date || '—' }}
+
+
+
Mode
+
Cash
+
+
+
Unpassed by
+
{{ active.unpass_by_name || '—' }}
+
+
+
Approved by
+
{{ active.unpass_approved_by_name || '—' }}
+
+
+
Reason
+
{{ active.reason }}
+
+
+ +
+ +
+
Posted to (wrong)
+
{{ active.wrong_account_no }}
+
{{ active.wrong_client_name || '—' }}
+
+ Balance: + @if (wrongAccount) { + ₹{{ wrongAccount.balance | number: '1.2-2' }} + @if (wrongHasBalance) { + check_circle covers the deposit + } @else { + cancel does not cover ₹{{ active.amount | number: '1.2-2' }} + } + } @else { + — + } +
+
+ + arrow_forward + + +
+
Correct account
+ + @if (lookingUp) { +
Looking up…
+ } + @if (correctAccount) { +
{{ correctAccount.clientName || '—' }}
+
+ Balance: ₹{{ correctAccount.balance | number: '1.2-2' }} + @if (correctOk) { + check_circle active, can receive + } +
+ } + @if (lookupError) { +
error {{ lookupError }}
+ } +
+
+ + @if (wrongAccount && !wrongHasBalance) { +
+ warning +
+ The customer has already used this money, so it cannot be taken back from + {{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account + from your own pocket as a fresh deposit (Transaction Entry), then press Close request. +
+
+ } +
+ +
+ + + +
+
+ } +
diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.scss b/src/app/cashier/voucher-correction/voucher-correction.component.scss new file mode 100644 index 000000000..3242e02c5 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.scss @@ -0,0 +1,599 @@ +/** + * 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/. + */ + +// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case. +.pg { + padding: 14px 20px; + gap: 12px; +} + +.pg-title { + font-size: 20px; + margin: 2px 0; +} + +.pg-sub { + font-size: 12px; +} + +.pg-btn { + height: 30px; + padding: 0 12px; + font-size: 12px; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.pg-table { + font-size: 12px; + + th { + padding: 8px 12px; + font-size: 10px; + } + + td { + padding: 7px 12px; + } +} + +// ── Steps ─────────────────────────────────────────────────────────────────── +.vc-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.vc-step { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + font-size: 12px; + color: #64748b; + + &__no { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + height: 20px; + border-radius: 50%; + background: #e2e8f0; + font-size: 11px; + font-weight: 700; + color: #475569; + } + + &--on { + border-color: #1f7a4d; + background: #f0fdf4; + color: #0f4f3c; + + .vc-step__no { + background: #1f7a4d; + color: #fff; + } + } +} + +// ── Tabs ──────────────────────────────────────────────────────────────────── +.vc-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; +} + +.vc-tab { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 7px 12px; + margin-bottom: -1px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + font: inherit; + font-size: 12px; + font-weight: 600; + color: #64748b; + cursor: pointer; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &:hover { + color: #0f4f3c; + } + + &--on { + color: #0f4f3c; + border-bottom-color: #1f7a4d; + } +} + +.vc-count { + padding: 0 7px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 10px; + font-weight: 700; +} + +// ── Search ────────────────────────────────────────────────────────────────── +.vc-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.vc-search { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 280px; + max-width: 420px; + height: 30px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + + &:focus-within { + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 12%); + } + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + color: #94a3b8; + } + + input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + font: inherit; + font-size: 12px; + } +} + +// ── Table ─────────────────────────────────────────────────────────────────── +.vc-table { + tbody tr { + cursor: pointer; + } + + tr.vc-row--on td { + background: #f0fdf4; + + &:first-child { + box-shadow: inset 3px 0 0 #1f7a4d; + } + } + + .vc-col-action { + width: 1%; + text-align: right; + white-space: nowrap; + } + + .vc-clip { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pg-chip { + font-size: 11px; + padding: 2px 8px; + } +} + +.vc-btn-sm { + height: 26px; + padding: 0 10px; + font-size: 11px; +} + +.vc-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +.vc-empty { + padding: 24px 12px; + text-align: center; + color: #94a3b8; + font-size: 12px; + + mat-icon { + display: block; + margin: 0 auto 4px; + font-size: 28px; + height: 28px; + width: 28px; + opacity: 0.6; + } +} + +// ── Popups ────────────────────────────────────────────────────────────────── +.vc-dlg { + display: flex; + flex-direction: column; + max-height: 86vh; + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + } + + &__ico { + flex: 0 0 32px; + height: 32px; + width: 32px; + border-radius: 9px; + background: #f0fdf4; + color: #1f7a4d; + font-size: 18px; + line-height: 32px; + text-align: center; + } + + &__titles { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__x { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + cursor: pointer; + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + + &:hover { + background: #e2e8f0; + } + } + + &__body { + padding: 12px 14px; + overflow-y: auto; + } + + &__amount { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 10px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #065f46; + font-size: 12px; + font-weight: 600; + + strong { + font-size: 18px; + font-weight: 800; + font-variant-numeric: tabular-nums; + } + } + + &__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.vc-warn { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 10px; + padding: 8px 12px; + border-radius: 8px; + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + font-size: 11px; + + mat-icon { + flex: 0 0 18px; + height: 18px; + width: 18px; + font-size: 18px; + } +} + +.vc-dlg__close-req { + margin-right: auto; + color: #b45309; +} + +.vc-label { + display: block; + margin-bottom: 3px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #94a3b8; +} + +.vc-reason { + width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + font-size: 12px; + resize: vertical; + + &:focus { + outline: 0; + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 12%); + } +} + +.vc-info { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 8px; + padding: 7px 10px; + border-radius: 8px; + background: #eff6ff; + color: #1e40af; + font-size: 11px; + + mat-icon { + flex: 0 0 16px; + height: 16px; + width: 16px; + font-size: 16px; + } +} + +.vc-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 6px; + margin-bottom: 10px; +} + +.vc-cell { + padding: 6px 10px; + border-radius: 8px; + background: #f8fafc; + border: 1px solid #eef2f7; +} + +.vc-cell--wide { + grid-column: 1 / -1; +} + +.vc-value { + font-size: 12px; + font-weight: 600; + color: #0f172a; + word-break: break-word; +} + +// ── Wrong → Correct ───────────────────────────────────────────────────────── +.vc-compare { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: 8px; + + @media (width <= 640px) { + grid-template-columns: 1fr; + } +} + +.vc-arrow { + align-self: center; + color: #94a3b8; + + @media (width <= 640px) { + transform: rotate(90deg); + justify-self: center; + } +} + +.vc-acct { + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + + &--wrong { + border-color: #fecaca; + background: #fef2f2; + } + + &--ok { + border-color: #a7f3d0; + background: #ecfdf5; + } + + &__tag { + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + } + + &__no { + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &__input { + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + font-size: 13px; + font-weight: 600; + + &:focus { + outline: 0; + border-color: #1f7a4d; + box-shadow: 0 0 0 3px rgb(31 122 77 / 12%); + } + } + + &__name { + margin-top: 4px; + font-size: 12px; + font-weight: 600; + color: #1e293b; + } + + &__bal { + margin-top: 3px; + font-size: 11px; + color: #475569; + } + + &__err { + margin-top: 6px; + } +} + +.vc-ok, +.vc-bad { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 6px; + font-weight: 700; + + mat-icon { + font-size: 14px; + height: 14px; + width: 14px; + } +} + +.vc-ok { + color: #059669; +} + +.vc-bad { + color: #dc2626; +} + +.vc-note { + margin-top: 3px; + font-size: 11px; + color: #64748b; +} + +// ── Status ────────────────────────────────────────────────────────────────── +.vc-status { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: #e2e8f0; + font-size: 11px; + font-weight: 700; + + &--UNPASS_PENDING, + &--CORRECTION_PENDING { + background: #fef3c7; + color: #92400e; + } + + &--UNPASSED { + background: #dbeafe; + color: #1e40af; + } + + &--COMPLETED { + background: #d1fae5; + color: #065f46; + } + + &--CLOSED { + background: #f1f5f9; + color: #475569; + } + + &--UNPASS_REJECTED { + background: #fee2e2; + color: #991b1b; + } +} diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.ts b/src/app/cashier/voucher-correction/voucher-correction.component.ts new file mode 100644 index 000000000..db114d5c2 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.ts @@ -0,0 +1,307 @@ +/** + * 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/. + */ + +import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { + AccountLookup, + EligibleVoucher, + VoucherCorrection, + VoucherCorrectionService +} from '../voucher-correction.service'; + +type Tab = 'today' | 'unpassed' | 'mine'; + +/** + * Cashier side of correcting a cash deposit that went to the wrong account. + * + * Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves). + * Unpassed: open the approved voucher, whose details are fixed, change only the account number and save + * (needs a manager approval again; only then do the accounts change). + */ +@Component({ + selector: 'mifosx-voucher-correction', + templateUrl: './voucher-correction.component.html', + styleUrls: [ + '../cashier-shared.scss', + './voucher-correction.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatDialogModule, + MatSnackBarModule + ] +}) +export class VoucherCorrectionComponent implements OnInit { + private service = inject(VoucherCorrectionService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + private auth = inject(AuthenticationService); + + /** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */ + readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS'); + readonly mayCorrect = this.auth.isCashierUser(); + + @ViewChild('unpassDialog') unpassDialog!: TemplateRef; + @ViewChild('correctionDialog') correctionDialog!: TemplateRef; + private dialogRef: MatDialogRef | null = null; + + tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine'; + + // Today vouchers + search = ''; + vouchers: EligibleVoucher[] = []; + loadingVouchers = false; + selectedVoucher: EligibleVoucher | null = null; + /** The account the voucher was posted to, for the balance and status shown in the popup. */ + voucherAccount: AccountLookup | null = null; + unpassReason = ''; + sending = false; + + // Unpassed vouchers waiting for the correct account + unpassed: VoucherCorrection[] = []; + loadingUnpassed = false; + active: VoucherCorrection | null = null; + wrongAccount: AccountLookup | null = null; + correctAccountNo = ''; + correctAccount: AccountLookup | null = null; + lookupError = ''; + lookingUp = false; + + // The cashier own requests + mine: VoucherCorrection[] = []; + loadingMine = false; + + readonly statusLabel: Record = { + UNPASS_PENDING: 'Unpass awaiting approval', + UNPASSED: 'Unpassed — enter correct account', + UNPASS_REJECTED: 'Unpass rejected', + CORRECTION_PENDING: 'Correction awaiting approval', + COMPLETED: 'Corrected', + CLOSED: 'Closed — cashier deposits separately' + }; + + ngOnInit(): void { + if (this.mayUnpass) this.loadVouchers(); + if (this.mayCorrect) this.loadUnpassed(); + if (this.tab === 'mine') this.loadMine(); + } + + setTab(tab: Tab): void { + this.tab = tab; + if (tab === 'today') this.loadVouchers(); + if (tab === 'unpassed') this.loadUnpassed(); + if (tab === 'mine') this.loadMine(); + } + + // ── Today vouchers ───────────────────────────────────────────────────────── + + loadVouchers(): void { + this.loadingVouchers = true; + this.service + .eligible(this.search.trim()) + .pipe(catchError(() => of([] as EligibleVoucher[]))) + .subscribe((rows) => { + this.vouchers = rows; + this.loadingVouchers = false; + if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) { + this.selectedVoucher = null; + } + }); + } + + pick(v: EligibleVoucher): void { + this.selectedVoucher = v; + this.unpassReason = ''; + this.voucherAccount = null; + this.service + .accountLookup(v.accountNo) + .pipe(catchError(() => of(null))) + .subscribe((a) => (this.voucherAccount = a)); + this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null)); + } + + closeUnpass(): void { + this.dialogRef?.close(); + } + + get canUnpass(): boolean { + return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending; + } + + unpass(): void { + if (!this.selectedVoucher || !this.canUnpass) return; + this.sending = true; + this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({ + next: () => { + this.sending = false; + this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', { + duration: 4000 + }); + this.dialogRef?.close(); + this.unpassReason = ''; + this.loadVouchers(); + }, + error: (err) => { + this.sending = false; + this.fail(err, 'Could not unpass the voucher'); + } + }); + } + + // ── Unpassed vouchers: enter the correct account ─────────────────────────── + + loadUnpassed(): void { + this.loadingUnpassed = true; + this.service + .list('UNPASSED') + .pipe(catchError(() => of([] as VoucherCorrection[]))) + .subscribe((rows) => { + this.unpassed = rows; + this.loadingUnpassed = false; + }); + } + + open(row: VoucherCorrection): void { + this.active = row; + this.correctAccountNo = ''; + this.correctAccount = null; + this.lookupError = ''; + this.wrongAccount = null; + // The account the deposit went to: its balance is what the "can this be taken back" check reads. + this.service + .accountLookup(row.wrong_account_no) + .pipe(catchError(() => of(null))) + .subscribe((a) => (this.wrongAccount = a)); + this.showDialog(this.correctionDialog, () => (this.active = null)); + } + + closeActive(): void { + this.dialogRef?.close(); + } + + onAccountInput(value: string): void { + this.correctAccountNo = value; + this.correctAccount = null; + this.lookupError = ''; + // Same rule as the Transaction Entry screen: searches on its own once six characters are typed. + if (value.trim().length >= 6) this.lookup(); + } + + lookup(): void { + const accountNo = this.correctAccountNo.trim(); + if (!accountNo || this.lookingUp) return; + this.lookingUp = true; + this.service.accountLookup(accountNo).subscribe({ + next: (a) => { + this.lookingUp = false; + // A late answer for a number no longer typed is dropped. + if (accountNo !== this.correctAccountNo.trim()) return; + this.correctAccount = a; + if (this.active && a.id === this.active.wrong_savings_account_id) { + this.lookupError = 'This is the account the deposit was already posted to.'; + } else if (!a.canReceive) { + this.lookupError = 'This account is not active, so it cannot receive the deposit.'; + } + }, + error: (err) => { + this.lookingUp = false; + this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.'; + } + }); + } + + /** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */ + get wrongHasBalance(): boolean { + return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount; + } + + get correctOk(): boolean { + return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError; + } + + get canSave(): boolean { + return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending; + } + + save(): void { + if (!this.active || !this.canSave) return; + this.sending = true; + this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({ + next: () => { + this.sending = false; + this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 }); + this.dialogRef?.close(); + this.loadUnpassed(); + }, + error: (err) => { + this.sending = false; + this.fail(err, 'Could not save the correction'); + } + }); + } + + /** + * Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier + * puts the amount into the right account from their own pocket as a fresh deposit instead. + */ + closeRequest(): void { + if (!this.active || this.sending) return; + const reason = this.wrongHasBalance + ? 'Closed by the cashier' + : 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately'; + this.sending = true; + this.service.close(this.active.id, reason).subscribe({ + next: () => { + this.sending = false; + this.snackBar.open('Request closed', 'Close', { duration: 4000 }); + this.dialogRef?.close(); + this.loadUnpassed(); + }, + error: (err) => { + this.sending = false; + this.fail(err, 'Could not close the request'); + } + }); + } + + // ── My requests ──────────────────────────────────────────────────────────── + + loadMine(): void { + this.loadingMine = true; + this.service + .list(undefined, true) + .pipe(catchError(() => of([] as VoucherCorrection[]))) + .subscribe((rows) => { + this.mine = rows; + this.loadingMine = false; + }); + } + + /** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */ + private showDialog(template: TemplateRef, onClose: () => void): void { + this.dialogRef?.close(); + this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false }); + this.dialogRef.afterClosed().subscribe(onClose); + } + + private fail(err: any, fallback: string): void { + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; + this.snackBar.open(msg, 'Close', { duration: 6000 }); + } +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html index 73ac94478..592554b20 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.html +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -169,6 +169,7 @@ Debit Credit Amount + Correction User + + + @if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') { + — + } + {{ row.username || '—' }} @if (systemOnly) { diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss index 1fe7a3b97..d7e7cc51e 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.scss +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -92,3 +92,8 @@ text-transform: uppercase; color: #7c3aed; } + +.vr-active { + font-size: 11px; + color: #94a3b8; +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index d6c148d91..3d04bd8a6 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; +import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component'; /** * Which side of the register a screen shows. @@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL'; /** One voucher as the register shows it: the number, the two sides, and who raised it. */ interface VoucherRow { voucherNo: string; + /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */ + voucherStatus?: VoucherStatus; + revokedByVoucherNo?: string | null; + replacesVoucherNo?: string | null; + correctionNote?: string | null; voucherType: string | null; amount: number | null; createdAt: string | null; @@ -82,7 +88,8 @@ interface VoucherReport { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ] }) export class VoucherReportComponent implements OnInit { diff --git a/src/app/cashier/voucher-status/voucher-status.component.ts b/src/app/cashier/voucher-status/voucher-status.component.ts new file mode 100644 index 000000000..a436de861 --- /dev/null +++ b/src/app/cashier/voucher-status/voucher-status.component.ts @@ -0,0 +1,137 @@ +/** + * 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/. + */ + +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +/** Where a voucher stands after a voucher correction. */ +export type VoucherStatus = + | 'ACTIVE' + | 'REVOKED' + | 'UNPASS_PENDING' + | 'UNPASSED' + | 'CORRECTION_PENDING' + | 'CORRECTED' + | 'CLOSED' + | null + | undefined; + +/** + * A small badge saying whether a voucher was revoked, is under correction, or replaced another one. + * Nothing is shown for an ordinary (active) voucher, so the registers read as before. + */ +@Component({ + selector: 'mifosx-voucher-status', + standalone: true, + imports: [CommonModule], + template: ` + @if (label) { + {{ label }} + @if (note) { +
{{ note }}
+ } + } + `, + styles: [ + ` + .vs { + display: inline-block; + margin-left: 6px; + padding: 1px 8px; + border-radius: 999px; + background: #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 700; + white-space: nowrap; + vertical-align: middle; + } + + .vs-note { + margin-top: 2px; + max-width: 280px; + font-size: 11px; + line-height: 1.3; + color: #64748b; + } + + .vs--REVOKED { + background: #fee2e2; + color: #991b1b; + } + + .vs--UNPASS_PENDING, + .vs--CORRECTION_PENDING { + background: #fef3c7; + color: #92400e; + } + + .vs--UNPASSED { + background: #dbeafe; + color: #1e40af; + } + + .vs--CLOSED { + background: #f1f5f9; + color: #475569; + } + + .vs--CORRECTED { + background: #d1fae5; + color: #065f46; + } + ` + ] +}) +export class VoucherStatusComponent { + @Input() status: VoucherStatus; + /** The voucher that replaced this one, when it is revoked. */ + @Input() revokedBy: string | null | undefined; + /** The voucher this one replaced, when it is the corrected one. */ + @Input() replaces: string | null | undefined; + /** Why: the reason the voucher was unpassed, or why the correction was closed. */ + @Input() note: string | null | undefined; + + get label(): string { + switch (this.status) { + case 'REVOKED': + return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked'; + case 'UNPASS_PENDING': + return 'Unpass pending'; + case 'UNPASSED': + return 'Unpassed'; + case 'CORRECTION_PENDING': + return 'Correction pending'; + case 'CLOSED': + return 'Closed — cashier deposits separately'; + case 'CORRECTED': + return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected'; + default: + return ''; + } + } + + get hint(): string { + switch (this.status) { + case 'REVOKED': + return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.'; + case 'UNPASS_PENDING': + return 'A manager has not yet approved unpassing this voucher.'; + case 'UNPASSED': + return 'Unpassed: waiting for the cashier to enter the correct account.'; + case 'CORRECTION_PENDING': + return 'The correct account is entered; waiting for a manager to approve.'; + case 'CLOSED': + return 'The correction was closed by the cashier; the voucher stays as it was.'; + case 'CORRECTED': + return 'This voucher was raised by a correction and replaces the earlier one.'; + default: + return ''; + } + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 420d10c4a..dab25a011 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -131,6 +131,14 @@ My Closure Requests + + + Voucher Correction + @if (clerkAccountLoaded) { diff --git a/src/app/savings/common-resolvers/savings-account-view.resolver.ts b/src/app/savings/common-resolvers/savings-account-view.resolver.ts index 264fa26ff..faf79efd1 100644 --- a/src/app/savings/common-resolvers/savings-account-view.resolver.ts +++ b/src/app/savings/common-resolvers/savings-account-view.resolver.ts @@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; /** rxjs Imports */ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; /** Custom Services */ import { SavingsService } from '../savings.service'; +import { VoucherCorrectionService } from '../../cashier/voucher-correction.service'; /** * Savings Account data resolver. @@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service'; @Injectable() export class SavingsAccountViewResolver { private savingsService = inject(SavingsService); + private voucherCorrectionService = inject(VoucherCorrectionService); /** - * Returns the Savings Account data. + * Returns the Savings Account data, without the deposits a completed voucher correction took off this account: + * the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong + * account and moved should not appear on it at all. * @param {ActivatedRouteSnapshot} route Route Snapshot * @returns {Observable} */ resolve(route: ActivatedRouteSnapshot): Observable { const savingAccountId = route.paramMap.get('savingAccountId'); - return this.savingsService.getSavingsAccountData(savingAccountId); + return this.savingsService.getSavingsAccountData(savingAccountId).pipe( + switchMap((account: any) => + this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe( + catchError(() => of([] as number[])), + map((voided: number[]) => { + if (voided.length && Array.isArray(account?.transactions)) { + account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id)); + } + return account; + }) + ) + ) + ); } } 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 65f82890f..d8b22a6c4 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 @@ -144,6 +144,24 @@ Withdrawal Requests + + + Cash Transfers + + + + Voucher Corrections + + import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then( + (m) => m.CashTransferApprovalsComponent + ), + data: { title: 'Cash Transfers' } + }, + { + path: 'voucher-corrections', + loadComponent: () => + import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then( + (m) => m.VoucherCorrectionApprovalsComponent + ), + data: { title: 'Voucher Corrections' } + }, { path: 'dividend-requests', component: DividendRequestsComponent,