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,