From e5d6cd32dc704bae89abe7342f3f99877e9d693e Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Fri, 12 Jun 2026 10:52:35 +0530 Subject: [PATCH] feat(cashier+checker): add cashier deposit maker-checker flow and voucher system - Add cashier deposit service and submit flow via maker-checker - New CashierDepositService: submit, approve, reject, list endpoints - Transaction-entry: cashier SD deposit goes to deposit-requests instead of direct credit - Checker inbox: new Deposit Requests tab with approve/reject review dialog - Cashier nav: My Deposit Requests tracking page - Withdrawal requests: show voucher_no from backend instead of JS-padded id - clerk-withdrawal.service: disburse denominationData accepts null for non-cash - Role-based checker enforcement: only manager/head-cashier can approve/reject - Maker cannot approve own request (self-approval blocked at backend) - Denomination total validation: breakdown must match transaction amount Co-Authored-By: Claude Sonnet 4.6 --- src/app/cashier/cashier-deposit.service.ts | 52 ++ src/app/cashier/cashier-routing.module.ts | 6 + src/app/cashier/clerk-withdrawal.service.ts | 2 +- .../my-deposit-requests.component.html | 248 +++++++ .../my-deposit-requests.component.scss | 416 ++++++++++++ .../my-deposit-requests.component.ts | 211 ++++++ .../transaction-entry.component.html | 31 +- .../transaction-entry.component.ts | 105 ++- .../my-requests/my-requests.component.html | 6 +- .../my-requests/my-requests.component.scss | 14 + src/app/core/shell/shell.component.html | 7 + .../deposit-requests.component.html | 297 +++++++++ .../deposit-requests.component.scss | 606 ++++++++++++++++++ .../deposit-requests.component.ts | 341 ++++++++++ .../withdrawal-requests.component.html | 6 +- .../withdrawal-requests.component.scss | 12 + .../checker-inbox-and-tasks.component.html | 12 + src/app/tasks/tasks-routing.module.ts | 6 + 18 files changed, 2360 insertions(+), 18 deletions(-) create mode 100644 src/app/cashier/cashier-deposit.service.ts create mode 100644 src/app/cashier/my-deposit-requests/my-deposit-requests.component.html create mode 100644 src/app/cashier/my-deposit-requests/my-deposit-requests.component.scss create mode 100644 src/app/cashier/my-deposit-requests/my-deposit-requests.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts diff --git a/src/app/cashier/cashier-deposit.service.ts b/src/app/cashier/cashier-deposit.service.ts new file mode 100644 index 000000000..e5efc0e83 --- /dev/null +++ b/src/app/cashier/cashier-deposit.service.ts @@ -0,0 +1,52 @@ +/** + * 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'; + +/** HTTP service for Cashier Deposit Request maker-checker workflow. */ +@Injectable({ providedIn: 'root' }) +export class CashierDepositService { + private http = inject(HttpClient); + + submit(data: any): Observable { + return this.http.post('/cashier/deposit-requests', data); + } + + update(id: number, data: any): Observable { + return this.http.put(`/cashier/deposit-requests/${id}`, data); + } + + getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit)); + if (status) params = params.set('status', status); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/cashier/deposit-requests/my', { params }); + } + + getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit)); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/cashier/deposit-requests', { params }); + } + + getById(id: number): Observable { + return this.http.get(`/cashier/deposit-requests/${id}`); + } + + approve(id: number): Observable { + return this.http.post(`/cashier/deposit-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/cashier/deposit-requests/${id}/reject`, { reason }); + } +} diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index c9ed042c9..136dc71a2 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -153,6 +153,12 @@ const routes: Routes = [ breadcrumb: 'Transaction Entry', hideShellLayout: true } + }, + { + path: 'my-deposit-requests', + loadComponent: () => + import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent), + data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' } } ] } diff --git a/src/app/cashier/clerk-withdrawal.service.ts b/src/app/cashier/clerk-withdrawal.service.ts index 9cd827f0c..27db75c4d 100644 --- a/src/app/cashier/clerk-withdrawal.service.ts +++ b/src/app/cashier/clerk-withdrawal.service.ts @@ -54,7 +54,7 @@ export class ClerkWithdrawalService { return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`); } - disburse(id: number, denominationData: string, transactionAmount?: number): Observable { + disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable { return this.http.post(`/clerk/withdrawal-requests/${id}/disburse`, { denominationData, transactionAmount: transactionAmount != null ? String(transactionAmount) : null diff --git a/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html new file mode 100644 index 000000000..04711b259 --- /dev/null +++ b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html @@ -0,0 +1,248 @@ + + +
+ +
+
+
CASHIER · DEPOSIT REQUESTS
+

My Deposit Requests

+

Track the status of your submitted deposit requests awaiting manager approval.

+
+ +
+ + +
+ + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + +
+ @if (loading) { +
Loading requests…
+ } @else if (loaded && requests.length === 0) { +
+ inbox +

No deposit requests found.

+ +
+ } @else { + + + + + + + + + + + + + + + + + @for (r of requests; track r.id) { + + + + + + + + + + + + + } + +
Voucher NoAccountClientAmountPayment ModeStatusSubmittedReviewed ByRejection ReasonAction
{{ r.voucher_no || '—' }}{{ r.account_no }}{{ r.client_name }}₹{{ r.amount | number: '1.2-2' }}{{ r.payment_type_name || 'Cash' }} + + {{ statusLabel(r.status) }} + + {{ formatDateTime(r.submitted_at) }}{{ r.manager_name || r.manager_username || '—' }} + @if (r.status === 'REJECTED' && r.rejection_reason) { + + {{ r.rejection_reason | slice: 0 : 60 }}{{ r.rejection_reason?.length > 60 ? '…' : '' }} + + } @else { + — + } + + +
+ + + } +
+
+ + +@if (viewRequest) { +
+
+
+
+
DEPOSIT REQUEST · VOUCHER {{ viewRequest.voucher_no || '—' }}
+
+ ₹{{ viewRequest.amount | number: '1.2-2' }} — {{ viewRequest.client_name }} +
+
+ +
+
+
+
+ Account No + {{ viewRequest.account_no }} +
+
+ Payment Mode + {{ viewRequest.payment_type_name || 'Cash' }} +
+
+ Status + + + {{ statusLabel(viewRequest.status) }} + + +
+
+ Submitted + {{ formatDateTime(viewRequest.submitted_at) }} +
+ @if (viewRequest.manager_name || viewRequest.manager_username) { +
+ Reviewed By + {{ viewRequest.manager_name || viewRequest.manager_username }} +
+ } + @if (viewRequest.actioned_at) { +
+ Actioned At + {{ formatDateTime(viewRequest.actioned_at) }} +
+ } + @if (viewRequest.txn_id) { +
+ Transaction Ref + {{ viewRequest.txn_id }} +
+ } + @if (viewRequest.description) { +
+ Description + {{ viewRequest.description }} +
+ } + @if (viewRequest.status === 'REJECTED' && viewRequest.rejection_reason) { +
+ Rejection Reason + {{ viewRequest.rejection_reason }} +
+ } +
+ @if (viewRequest.status === 'REJECTED') { +
This request was rejected. Enter a fresh deposit at Transaction Entry to resubmit.
+ } +
+
+ + @if (viewRequest.status === 'REJECTED') { + + } +
+
+
+} diff --git a/src/app/cashier/my-deposit-requests/my-deposit-requests.component.scss b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.scss new file mode 100644 index 000000000..ac390b0df --- /dev/null +++ b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.scss @@ -0,0 +1,416 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + display: block; +} + +.mdr-table { + td, + th { + font-size: 13px; + } +} + +.mdr-row { + cursor: default; + transition: background 0.1s; + + &:hover { + background: #f8fafc; + } + + &--rejected td { + background: #fff5f5; + } + + &--approved td { + background: #f0fdf4; + } +} + +.mdr-voucher { + font-weight: 700; + color: #1e40af; + letter-spacing: 0.06em; + font-size: 12px; +} + +.mdr-status { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; + + &--pending { + background: #fef3c7; + color: #92400e; + } + + &--approved { + background: #dcfce7; + color: #166534; + } + + &--rejected { + background: #fee2e2; + color: #991b1b; + } +} + +.mdr-rejection { + max-width: 200px; +} + +.mdr-rejection-text { + font-size: 12px; + color: #991b1b; + font-style: italic; +} + +.mdr-none { + color: #94a3b8; +} + +.mdr-actions-cell { + white-space: nowrap; +} + +.mdr-btn-view { + font-size: 12px; + height: 30px; + padding: 0 12px; +} + +.mdr-th-action { + width: 100px; +} + +.mdr-btn-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + cursor: pointer; + color: #64748b; + font-family: inherit; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover:not(:disabled) { + background: #f1f5f9; + color: #0d1b2a; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.mdr-select { + height: 32px; + min-width: 120px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + cursor: pointer; + + &:focus { + outline: none; + border-color: #1353a0; + } +} + +.pg-filter-row { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + flex-wrap: wrap; +} + +.pg-fb-sep { + width: 1px; + height: 22px; + background: #e2e8f0; + flex-shrink: 0; + margin: 0 4px; +} + +.pg-presets { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.pg-preset-chip { + height: 28px; + padding: 0 12px; + border-radius: 14px; + border: 1px solid #e2e8f0; + background: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + color: #475569; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + border-color: #1353a0; + color: #1353a0; + background: #eff6ff; + } + + &--active { + background: #1353a0; + border-color: #1353a0; + color: #fff; + + &:hover { + background: #0f4080; + } + } +} + +.pg-date-range { + display: inline-flex; + align-items: center; + gap: 4px; + + &__sep { + color: #94a3b8; + font-size: 16px; + font-weight: 300; + padding: 0 2px; + user-select: none; + } +} + +.pg-date-box { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 0 6px 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + transition: border-color 0.15s; + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #1353a0; + } + + &__lbl { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + white-space: nowrap; + user-select: none; + letter-spacing: 0.03em; + } + + &__input { + border: none; + outline: none; + font-size: 13px; + width: 72px; + color: #0d1b2a; + font-family: inherit; + background: transparent; + cursor: pointer; + + &::placeholder { + color: #cbd5e1; + } + } + + &__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + background: none; + cursor: pointer; + color: #94a3b8; + padding: 0; + border-radius: 4px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + color: #1353a0; + background: #eff6ff; + } + } +} + +// ── View modal ────────────────────────────────────────────────────────────── + +.mdr-modal-overlay { + position: fixed; + inset: 0; + background: rgb(13 27 42 / 55%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} + +.mdr-modal { + background: #fff; + border-radius: 14px; + width: min(560px, 100%); + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 64px rgb(13 27 42 / 35%); + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 18px 22px; + background: linear-gradient(135deg, #1e40af, #1e3a8a); + color: #fff; + } + + &__eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + opacity: 0.85; + } + + &__title { + font-size: 18px; + font-weight: 700; + margin-top: 4px; + } + + &__close { + border: none; + background: rgb(255 255 255 / 15%); + color: #fff; + width: 30px; + height: 30px; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + background: rgb(255 255 255 / 28%); + } + } + + &__body { + padding: 18px 22px; + overflow-y: auto; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 22px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.mdr-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 18px; +} + +.mdr-detail { + display: flex; + flex-direction: column; + gap: 2px; + + &--wide { + grid-column: 1 / -1; + } + + &__lbl { + font-size: 11px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__val { + font-size: 13px; + color: #0d1b2a; + font-weight: 500; + + &--reject { + color: #b91c1c; + } + } +} + +.mdr-hint { + margin-top: 14px; + padding: 10px 14px; + border-radius: 8px; + background: #fef3c7; + color: #92400e; + font-size: 13px; + font-weight: 600; +} + +.cell-mono { + font-family: 'Courier New', monospace; + font-size: 12px; +} diff --git a/src/app/cashier/my-deposit-requests/my-deposit-requests.component.ts b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.ts new file mode 100644 index 000000000..eefa98cd5 --- /dev/null +++ b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.ts @@ -0,0 +1,211 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { CashierDepositService } from '../cashier-deposit.service'; +import { SettingsService } from '../../settings/settings.service'; + +@Component({ + selector: 'mifosx-my-deposit-requests', + templateUrl: './my-deposit-requests.component.html', + styleUrls: [ + '../cashier-shared.scss', + './my-deposit-requests.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatPaginatorModule, + MatNativeDateModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule + ] +}) +export class MyDepositRequestsComponent implements OnInit { + private service = inject(CashierDepositService); + private router = inject(Router); + private settingsService = inject(SettingsService); + + requests: any[] = []; + totalRecords = 0; + pageSize = 20; + pageIndex = 0; + pageSizeOptions = [ + 10, + 20, + 50 + ]; + + filterStatus = ''; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset: string = 'today'; + loading = false; + loaded = false; + + viewRequest: any = null; + + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: 'This Week' }, + { key: 'month', label: 'This Month' }, + { key: 'all', label: 'All' } + ]; + + readonly statusOptions = [ + { value: '', label: 'All Statuses' }, + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.applyPreset('today'); + } + + load(): void { + this.loading = true; + this.service + .getMyRequests( + this.filterStatus || undefined, + this.pageIndex * this.pageSize, + this.pageSize, + this.isoDate(this.fromDate), + this.isoDate(this.toDate) + ) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + onFilterChange(): void { + this.pageIndex = 0; + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.pageIndex = 0; + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.pageIndex = 0; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'week') { + const start = new Date(today); + start.setDate(today.getDate() - today.getDay()); + this.fromDate = start; + this.toDate = new Date(today); + } else if (key === 'month') { + this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + this.toDate = new Date(today); + } else { + this.fromDate = null; + this.toDate = null; + } + this.pageIndex = 0; + this.load(); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) return undefined; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + newDeposit(): void { + this.router.navigate(['/cashier/transaction-entry']); + } + + view(req: any): void { + this.viewRequest = req; + } + + closeView(): void { + this.viewRequest = null; + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'mdr-status--pending', + APPROVED: 'mdr-status--approved', + REJECTED: 'mdr-status--rejected' + }; + return map[status] ?? ''; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending Approval', + APPROVED: 'Approved & Credited', + REJECTED: 'Rejected' + }; + return map[status] ?? status; + } + + formatDateTime(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index c6971e13d..4aebe762e 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -1415,9 +1415,9 @@ class="te-confirm-header" [class.te-confirm-header--deposit]="confirmData.type === 'DEPOSIT'" [class.te-confirm-header--withdrawal]="confirmData.type === 'WITHDRAWAL'" - [class.te-confirm-header--disbursement]="confirmData.isDisbursement" + [class.te-confirm-header--disbursement]="confirmData.isDisbursement || confirmData.isDepositRequest" > - {{ confirmData.type === 'DEPOSIT' ? '⬇' : '⬆' }} + {{ confirmData.type === 'WITHDRAWAL' ? '⬆' : '⬇' }} Confirm {{ confirmData.isDisbursement ? 'CASH DISBURSEMENT' : confirmData.type }}
@@ -1465,6 +1465,25 @@ >Balance already deducted at manager approval — no further deduction
+ } @else if (confirmData.isDepositRequest) { + +
+ Current Balance + {{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }} +
+
+ Deposit Amount + +{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }} +
+
+ Sent for manager approval — balance will be credited only after approval +
} @else {
Current BalanceCancel
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 93b4a1b2d..4a21d1193 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -19,6 +19,7 @@ import { CashierService } from '../cashier.service'; import { AuthenticationService } from '../../core/authentication/authentication.service'; import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service'; import { ClerkWithdrawalService } from '../clerk-withdrawal.service'; +import { CashierDepositService } from '../cashier-deposit.service'; import { SettingsService } from '../../settings/settings.service'; interface TxnRow { @@ -653,6 +654,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private dialog = inject(MatDialog); private clerkUiState = inject(ClerkUiStateService); private clerkWithdrawalService = inject(ClerkWithdrawalService); + private cashierDepositService = inject(CashierDepositService); private router = inject(Router); private settingsService = inject(SettingsService); @@ -2277,15 +2279,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void { // Token-based cashier disbursement: balance was already deducted at manager approval const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest; - const balanceAfterTxn = isDisbursement - ? this.currentBalance // no further deduction — just handing cash - : isDeposit - ? this.currentBalance + amount - : this.currentBalance - amount; + // Cashier savings deposit goes via maker-checker: balance credits only after manager approval + const isDepositRequest = isDeposit && !this.isClerk && row.acTyp === 'SD'; + const balanceAfterTxn = + isDisbursement || isDepositRequest + ? this.currentBalance // no immediate balance change + : isDeposit + ? this.currentBalance + amount + : this.currentBalance - amount; this.confirmData = { - type: isDisbursement ? 'DISBURSEMENT' : isDeposit ? 'DEPOSIT' : 'WITHDRAWAL', + type: isDisbursement + ? 'DISBURSEMENT' + : isDepositRequest + ? 'DEPOSIT REQUEST' + : isDeposit + ? 'DEPOSIT' + : 'WITHDRAWAL', isDisbursement, + isDepositRequest, accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo, clientName: this.clientName, amount, @@ -2320,6 +2332,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } + // Cashier savings deposit → route through maker-checker (create PENDING request, no direct credit) + // Use the dialog-confirmed amount — for cash that is the denomination total + if (!this.isClerk && !this.reviewMode && isDeposit && row.acTyp === 'SD') { + this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount); + return; + } + // Hard block: cashier direct withdrawal never reaches here — caught in onSave() // Extra safety net in case confirmSubmit() is ever called directly if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) { @@ -2334,7 +2353,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Do NOT post another savings transaction — just record disbursement if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) { this.saving = true; - const denomJson = JSON.stringify(this.denominationCounts); + // Send denominations only for cash payments — stale counts on a cheque + // disbursement would fail the backend denomination-total check + const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); + const denomJson = + selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null; const req = this.activeWithdrawalRequest; this.clerkWithdrawalService .disburse(req.id, denomJson, amount) @@ -2352,9 +2375,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .subscribe((res: any) => { this.saving = false; if (res) { + const disbVoucher = res.voucherNo || req.voucher_no || '—'; this.showAlert('success', 'Cash Disbursed Successfully', [ `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`, - `Token No: ${req.tok_no} · Client: ${req.client_name}` + `Token No: ${req.tok_no} · Client: ${req.client_name}`, + `Voucher No: ${disbVoucher}` ]); this.activeWithdrawalRequest = null; this.clearDenominations(); @@ -2646,18 +2671,78 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .subscribe((res: any) => { this.saving = false; if (res) { - const ref = res.resourceId ?? res.requestId ?? res.id ?? 'N/A'; const tok = row.tokNo?.trim() ?? '—'; + const voucherNo = res.voucherNo ?? '—'; this.showAlert('success', 'Withdrawal Request Submitted', [ `Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`, 'Balance will be deducted only after approval.', - `Token No: ${tok} · Request No: ${ref}` + `Token No: ${tok} · Voucher No: ${voucherNo}` ]); this.onCancel(); } }); } + /** Build the deposit-request payload from the current row + loaded account. */ + private buildDepositPayload(row: TxnRow, amount: number): any { + const today = new Date(); + const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); + // Send denominations only for cash payments — stale counts on a cheque + // deposit would fail the backend denomination-total check + const isCash = !!selectedPt?.isCashPayment; + + return { + accountNo: this.accountDetails.accountNo, + accountType: 'SAVINGS', + savingsAccountId: this.accountDetails.id, + clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null, + clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null, + amount, + txnDate: txnDateIso, + paymentTypeId: row.paymentTypeId || null, + paymentTypeName: selectedPt?.name ?? 'Cash', + description: row.description || null, + chequeNo: row.chequeNo || null, + bankName: row.bankName || null, + denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null + }; + } + + /** Cashier maker-checker: create a PENDING deposit request instead of crediting balance directly. */ + private submitCashierDepositRequest(row: TxnRow, amount: number): void { + const payload = this.buildDepositPayload(row, amount); + + this.saving = true; + this.cashierDepositService + .submit(payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Failed to submit deposit request. Please try again.'; + this.showAlert('error', 'Deposit Request Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + const voucherNo = res.voucherNo ?? '—'; + this.showAlert('success', 'Deposit Request Submitted', [ + `Deposit of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`, + 'Balance will be credited only after approval.', + `Voucher No: ${voucherNo}` + ]); + this.clearDenominations(); + this.onCancel(); + } + }); + } + onCancel(): void { this.clerkUiState.clear(); this.accountDetails = null; diff --git a/src/app/clerk/my-requests/my-requests.component.html b/src/app/clerk/my-requests/my-requests.component.html index 5cec978ec..d4f59c8c1 100644 --- a/src/app/clerk/my-requests/my-requests.component.html +++ b/src/app/clerk/my-requests/my-requests.component.html @@ -100,7 +100,8 @@ - + + @@ -121,7 +122,8 @@ [class.myr-row--rejected]="r.status === 'REJECTED'" [class.myr-row--disbursed]="r.status === 'DISBURSED'" > - + + diff --git a/src/app/clerk/my-requests/my-requests.component.scss b/src/app/clerk/my-requests/my-requests.component.scss index d55f91969..867587fe6 100644 --- a/src/app/clerk/my-requests/my-requests.component.scss +++ b/src/app/clerk/my-requests/my-requests.component.scss @@ -34,6 +34,20 @@ } } +.myr-voucher { + font-weight: 700; + color: #1e40af; + letter-spacing: 0.06em; + font-size: 12px; +} + +.myr-token { + font-weight: 700; + color: #0f766e; + letter-spacing: 0.06em; + font-size: 12px; +} + .myr-status { display: inline-flex; align-items: center; diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index c707ce78d..e3304e59a 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -144,6 +144,13 @@ > Transaction Entry + + My Deposits + + +
+ +
+
+ savings +
+
Deposit Requests
+
Review, approve or reject cashier deposit requests.
+
+
+
+ + +
+ + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + + @if (loading) { +
+

Loading…

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

No deposit requests

+

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

+
+ } @else { +
+
#Voucher NoToken No Account Client Amount{{ r.id }}{{ r.voucher_no || '—' }}{{ r.tok_no || '—' }} {{ r.account_no }} {{ r.client_name }} ₹{{ r.amount | number: '1.2-2' }}
+ + + + + + + + + + + + + + + + @for (r of requests; track r.id) { + + + + + + + + + + + + + } + +
Voucher NoAccount NoClient NameAmountPayment ModeCashierReviewed BySubmitted AtStatusAction
{{ r.voucher_no || '—' }}{{ r.account_no }}{{ r.client_name }}₹{{ r.amount | number: '1.2-2' }}{{ r.payment_type_name || 'Cash' }}{{ r.cashier_name }}{{ r.manager_name || r.manager_username || '—' }}{{ formatDateTime(r.submitted_at) }} + + {{ statusLabel(r.status) }} + + + +
+ + + + } + + + +@if (reviewRequest) { +
+
+
+
+
DEPOSIT REQUEST · VOUCHER {{ reviewRequest.voucher_no || '—' }}
+
+ ₹{{ reviewRequest.amount | number: '1.2-2' }} — {{ reviewRequest.client_name }} +
+
+ +
+ +
+
+
+ Account No + {{ reviewRequest.account_no }} +
+
+ Account Type + {{ reviewRequest.account_type || 'SAVINGS' }} +
+
+ Payment Mode + {{ reviewRequest.payment_type_name || 'Cash' }} +
+
+ Cashier + {{ reviewRequest.cashier_name }} +
+
+ Submitted At + {{ formatDateTime(reviewRequest.submitted_at) }} +
+ @if (reviewRequest.cheque_no) { +
+ Cheque No + {{ reviewRequest.cheque_no }} +
+ } + @if (reviewRequest.bank_name) { +
+ Bank + {{ reviewRequest.bank_name }} +
+ } + @if (reviewRequest.description) { +
+ Description + {{ reviewRequest.description }} +
+ } + @if (reviewRequest.status === 'REJECTED' && reviewRequest.rejection_reason) { +
+ Rejection Reason + {{ reviewRequest.rejection_reason }} +
+ } +
+ + @if (reviewDenominations.length > 0) { +
+
Cash Denomination (received by cashier)
+ + + + + + + + + + @for (d of reviewDenominations; track d.label) { + + + + + + } + + + + + + +
DenominationCountSubtotal
{{ d.label }}{{ d.count }}₹{{ d.subtotal | number: '1.2-2' }}
Total₹{{ denominationTotal | number: '1.2-2' }}
+
+ } + + @if (actionResult) { +
+ @for (line of actionResult.lines; track line) { +
{{ line }}
+ } +
+ } + + @if (showRejectInput) { +
+ + +
+ } +
+ +
+ @if (reviewRequest.status === 'PENDING' && !actionResult) { + @if (showRejectInput) { + + + } @else { + + + + } + } @else { + + } +
+
+
+} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss new file mode 100644 index 000000000..06fc3df0a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss @@ -0,0 +1,606 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + display: block; +} + +.dri-tab { + padding: 0; +} + +.dri-action-bar { + display: flex; + align-items: center; + padding: 14px 20px; + background: #fff; + border-bottom: 1px solid #e2e8f0; + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + font-size: 28px; + width: 28px; + height: 28px; + color: #1e40af; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + + &__sub { + font-size: 12px; + color: #64748b; + margin-top: 2px; + } +} + +.dri-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; +} + +.dri-fb-sep { + width: 1px; + height: 22px; + background: #e2e8f0; + flex-shrink: 0; + margin: 0 4px; +} + +.dri-filter-select { + height: 34px; + min-width: 130px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + cursor: pointer; + + &:focus { + outline: none; + border-color: #1e40af; + } +} + +.dri-presets { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.dri-preset-chip { + height: 28px; + padding: 0 12px; + border-radius: 14px; + border: 1px solid #e2e8f0; + background: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + color: #475569; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + border-color: #1e40af; + color: #1e40af; + background: #eff6ff; + } + + &--active { + background: #1e40af; + border-color: #1e40af; + color: #fff; + + &:hover { + background: #1e3a8a; + } + } +} + +.dri-date-range { + display: inline-flex; + align-items: center; + gap: 4px; + + &__sep { + color: #94a3b8; + font-size: 16px; + font-weight: 300; + padding: 0 2px; + user-select: none; + } +} + +.dri-date-box { + display: inline-flex; + align-items: center; + gap: 4px; + height: 34px; + padding: 0 6px 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + transition: border-color 0.15s; + + &:hover { + border-color: #cbd5e1; + } + + &:focus-within { + border-color: #1e40af; + } + + &__lbl { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + white-space: nowrap; + user-select: none; + letter-spacing: 0.03em; + } + + &__input { + border: none; + outline: none; + font-size: 13px; + width: 76px; + color: #0d1b2a; + font-family: inherit; + background: transparent; + cursor: pointer; + + &::placeholder { + color: #cbd5e1; + } + } + + &__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + background: none; + cursor: pointer; + color: #94a3b8; + padding: 0; + border-radius: 4px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + color: #1e40af; + background: #eff6ff; + } + } +} + +.dri-btn { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 7px; + font-family: inherit; + font-weight: 600; + cursor: pointer; + border: 1px solid #e2e8f0; + transition: background 0.15s; + height: 32px; + padding: 0 12px; + font-size: 13px; + background: #fff; + color: #0d1b2a; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &--refresh { + padding: 0 8px; + color: #64748b; + + &:hover:not(:disabled) { + background: #f1f5f9; + } + } + + &--review { + background: #1e40af; + color: #fff; + border-color: #1e40af; + + &:hover { + background: #1e3a8a; + } + } + + &--approve { + background: #15803d; + color: #fff; + border-color: #15803d; + + &:hover:not(:disabled) { + background: #166534; + } + } + + &--reject { + background: #b91c1c; + color: #fff; + border-color: #b91c1c; + + &:hover:not(:disabled) { + background: #991b1b; + } + } +} + +.dri-table-wrap { + overflow-x: auto; +} + +.dri-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid #f1f5f9; + } + + th { + font-size: 11px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; + background: #f8fafc; + white-space: nowrap; + } + + .num { + text-align: right; + } +} + +.dri-row { + cursor: pointer; + transition: background 0.1s; + + &:hover td { + background: #eff6ff; + } +} + +.dri-row--approved td { + background: #f0fdf4; +} + +.dri-th-id { + width: 90px; +} + +.dri-th-action { + width: 100px; + text-align: center; +} + +.dri-action-cell { + text-align: center; +} + +.dri-client { + font-weight: 500; +} + +.dri-amount { + font-weight: 700; + color: #1e40af; +} + +.dri-dt { + font-size: 12px; + color: #64748b; + white-space: nowrap; +} + +.dri-status { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; + + &--pending { + background: #fef3c7; + color: #92400e; + } + + &--approved { + background: #dcfce7; + color: #166534; + } + + &--rejected { + background: #fee2e2; + color: #991b1b; + } +} + +.cell-mono { + font-family: 'Courier New', monospace; + font-size: 12px; +} + +.dri-voucher { + font-weight: 700; + color: #1e40af; + letter-spacing: 0.06em; +} + +// ── Review modal ──────────────────────────────────────────────────────────── + +.dri-modal-overlay { + position: fixed; + inset: 0; + background: rgb(13 27 42 / 55%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} + +.dri-modal { + background: #fff; + border-radius: 14px; + width: min(640px, 100%); + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 64px rgb(13 27 42 / 35%); + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 18px 22px; + background: linear-gradient(135deg, #1e40af, #1e3a8a); + color: #fff; + } + + &__eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + opacity: 0.85; + } + + &__title { + font-size: 18px; + font-weight: 700; + margin-top: 4px; + } + + &__close { + border: none; + background: rgb(255 255 255 / 15%); + color: #fff; + width: 30px; + height: 30px; + border-radius: 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + background: rgb(255 255 255 / 28%); + } + } + + &__body { + padding: 18px 22px; + overflow-y: auto; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 22px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + } +} + +.dri-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 18px; +} + +.dri-detail { + display: flex; + flex-direction: column; + gap: 2px; + + &--wide { + grid-column: 1 / -1; + } + + &__lbl { + font-size: 11px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__val { + font-size: 13px; + color: #0d1b2a; + font-weight: 500; + + &--reject { + color: #b91c1c; + } + } +} + +.dri-denoms { + margin-top: 16px; + + &__title { + font-size: 12px; + font-weight: 700; + color: #475569; + margin-bottom: 6px; + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 6px 10px; + border-bottom: 1px solid #f1f5f9; + text-align: left; + } + + th { + font-size: 11px; + font-weight: 700; + color: #64748b; + background: #f8fafc; + } + + .num { + text-align: right; + } + } + + &__total td { + font-weight: 700; + color: #1e40af; + border-top: 2px solid #e2e8f0; + } +} + +.dri-result { + margin-top: 14px; + padding: 10px 14px; + border-radius: 8px; + background: #dcfce7; + color: #166534; + font-size: 13px; + font-weight: 600; + + &--error { + background: #fee2e2; + color: #991b1b; + } +} + +.dri-reject-box { + margin-top: 14px; + + &__lbl { + display: block; + font-size: 12px; + font-weight: 700; + color: #475569; + margin-bottom: 4px; + } + + &__input { + width: 100%; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + font-family: inherit; + resize: vertical; + + &:focus { + outline: none; + border-color: #b91c1c; + } + } +} + +// Empty state (re-use checker inbox styles) +.empty-state { + text-align: center; + padding: 48px 24px; + color: #94a3b8; + + .empty-icon { + font-size: 40px; + color: #cbd5e1; + } + + .empty-title { + font-size: 16px; + font-weight: 600; + color: #64748b; + margin: 12px 0 4px; + } + + .empty-text { + font-size: 13px; + color: #94a3b8; + margin: 0; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts new file mode 100644 index 000000000..21b8c8393 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts @@ -0,0 +1,341 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { CashierDepositService } from '../../../cashier/cashier-deposit.service'; +import { SettingsService } from '../../../settings/settings.service'; + +interface DenomLine { + label: string; + kind: string; + value: number; + count: number; + subtotal: number; +} + +@Component({ + selector: 'mifosx-deposit-requests', + templateUrl: './deposit-requests.component.html', + styleUrls: ['./deposit-requests.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatPaginatorModule, + MatNativeDateModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule + ] +}) +export class DepositRequestsComponent implements OnInit { + private service = inject(CashierDepositService); + private settingsService = inject(SettingsService); + + requests: any[] = []; + totalRecords = 0; + pageSize = 20; + pageIndex = 0; + pageSizeOptions = [ + 10, + 20, + 50 + ]; + + filterStatus = 'PENDING'; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset: string = 'today'; + loading = false; + loaded = false; + + // Review modal state + reviewRequest: any = null; + reviewDenominations: DenomLine[] = []; + showRejectInput = false; + rejectionReason = ''; + actioning = false; + actionResult: { type: 'success' | 'error'; lines: string[] } | null = null; + + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: 'This Week' }, + { key: 'month', label: 'This Month' }, + { key: 'all', label: 'All' } + ]; + + get statusOptions(): { value: string; label: string }[] { + return [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + } + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.applyPreset('today'); + } + + load(): void { + this.loading = true; + this.service + .getPendingRequests( + this.filterStatus, + this.pageIndex * this.pageSize, + this.pageSize, + this.isoDate(this.fromDate), + this.isoDate(this.toDate) + ) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + onFilterChange(): void { + this.pageIndex = 0; + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.pageIndex = 0; + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.pageIndex = 0; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'week') { + const start = new Date(today); + start.setDate(today.getDate() - today.getDay()); + this.fromDate = start; + this.toDate = new Date(today); + } else if (key === 'month') { + this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + this.toDate = new Date(today); + } else { + this.fromDate = null; + this.toDate = null; + } + this.pageIndex = 0; + this.load(); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) return undefined; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + // ── Review modal ────────────────────────────────────────────────────────── + + review(req: any): void { + this.reviewRequest = req; + this.reviewDenominations = this.parseDenominations(req.denomination_data); + this.showRejectInput = false; + this.rejectionReason = ''; + this.actionResult = null; + } + + closeReview(): void { + if (this.actioning) return; + this.reviewRequest = null; + this.reviewDenominations = []; + this.showRejectInput = false; + this.rejectionReason = ''; + this.actionResult = null; + } + + /** denomination_data format: {"note:500": 2, "coin:1": 10} */ + private parseDenominations(raw: string | null): DenomLine[] { + if (!raw) return []; + let map: Record; + try { + map = JSON.parse(raw); + } catch { + return []; + } + const lines: DenomLine[] = []; + for (const key of Object.keys(map ?? {})) { + const count = Number(map[key]) || 0; + if (count <= 0) continue; + const [ + kind, + valStr + ] = key.split(':'); + const value = Number(valStr) || 0; + if (!value) continue; + lines.push({ + label: `${kind === 'coin' ? 'Coin' : 'Note'} ₹${value}`, + kind, + value, + count, + subtotal: value * count + }); + } + return lines.sort((a, b) => b.value - a.value || a.kind.localeCompare(b.kind)); + } + + get denominationTotal(): number { + return this.reviewDenominations.reduce((s, d) => s + d.subtotal, 0); + } + + approve(): void { + if (!this.reviewRequest || this.actioning) return; + this.actioning = true; + this.service + .approve(this.reviewRequest.id) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Approval failed. Please try again.'; + this.actionResult = { type: 'error', lines: [msg] }; + this.actioning = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioning = false; + if (res) { + this.reviewRequest.status = 'APPROVED'; + this.actionResult = { + type: 'success', + lines: [ + 'Deposit approved — amount credited to the customer account.', + res.txnId ? `Transaction Ref: ${res.txnId}` : '' + ].filter(Boolean) + }; + this.load(); + } + }); + } + + openReject(): void { + this.showRejectInput = true; + this.rejectionReason = ''; + } + + cancelReject(): void { + this.showRejectInput = false; + this.rejectionReason = ''; + } + + confirmReject(): void { + if (!this.reviewRequest || this.actioning) return; + if (!this.rejectionReason.trim()) { + this.actionResult = { type: 'error', lines: ['Please enter a reason for rejecting this request.'] }; + return; + } + this.actioning = true; + this.service + .reject(this.reviewRequest.id, this.rejectionReason.trim()) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Rejection failed. Please try again.'; + this.actionResult = { type: 'error', lines: [msg] }; + this.actioning = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioning = false; + if (res) { + this.showRejectInput = false; + this.reviewRequest.status = 'REJECTED'; + this.reviewRequest.rejection_reason = this.rejectionReason.trim(); + this.actionResult = { + type: 'success', + lines: ['Deposit request rejected — sent back to the cashier.'] + }; + this.load(); + } + }); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'dri-status--pending', + APPROVED: 'dri-status--approved', + REJECTED: 'dri-status--rejected' + }; + return map[status] ?? ''; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending', + APPROVED: 'Approved', + REJECTED: 'Rejected' + }; + return map[status] ?? status; + } + + formatDateTime(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.html index f1d7ec0b2..3f0a25aae 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.html @@ -105,7 +105,8 @@ - + + @@ -121,7 +122,8 @@ @for (r of requests; track r.id) { - + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.scss index 34dabc60b..dc2613c1e 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.scss @@ -387,6 +387,18 @@ font-size: 12px; } +.wri-voucher { + font-weight: 700; + color: #1e40af; + letter-spacing: 0.06em; +} + +.wri-token { + font-weight: 700; + color: #0f766e; + letter-spacing: 0.06em; +} + // Empty state (re-use checker inbox styles) .empty-state { text-align: center; 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 33b3fdbc7..5b0eeb230 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 @@ -118,6 +118,18 @@ Withdrawal Requests + + + Deposit Requests + diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index df4d2b9b7..73c6942c7 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -17,6 +17,7 @@ import { Route } from '../core/route/route.service'; import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component'; import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component'; import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component'; +import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component'; import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component'; import { LoanApprovalComponent } from './checker-inbox-and-tasks-tabs/loan-approval/loan-approval.component'; import { LoanDisbursalComponent } from './checker-inbox-and-tasks-tabs/loan-disbursal/loan-disbursal.component'; @@ -107,6 +108,11 @@ const routes: Routes = [ path: 'withdrawal-requests', component: WithdrawalRequestsComponent, data: { title: 'Withdrawal Requests' } + }, + { + path: 'deposit-requests', + component: DepositRequestsComponent, + data: { title: 'Deposit Requests' } } ] },
#Voucher NoToken No Account No Client Name Amount
{{ r.id }}{{ r.voucher_no || '—' }}{{ r.tok_no || '—' }} {{ r.account_no }} {{ r.client_name }} ₹{{ r.amount | number: '1.2-2' }}