From 61cb09f3521b2296dec9ee1544b86e048e2c56fe Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Thu, 11 Jun 2026 15:57:33 +0530 Subject: [PATCH] feat(cashier): complete maker-checker withdrawal disbursement flow - Clerk manually enters token no with today-date unique validation (backend enforced) - Cashier token lookup: auto-fill amount/account, lock amount field, status-specific errors - Confirm dialog shows CASH DISBURSEMENT (blue) with no balance re-deduction warning - Disbursement denomination saved to m_savings_txn_denomination for vault position update - Block direct cashier withdrawal without approved maker-checker token - DISBURSED status shown in My Requests and Checker inbox views with cashier/time info - Remove getNextToken() service method and m_daily_token_counter table (0326 migration) - Race condition protection: UPDATE WHERE status=APPROVED prevents double disbursement Co-Authored-By: Claude Sonnet 4.6 --- src/app/cashier/cashier-routing.module.ts | 19 + src/app/cashier/clerk-withdrawal.service.ts | 63 ++ .../transaction-entry.component.html | 568 +++++++++----- .../transaction-entry.component.scss | 312 +++++++- .../transaction-entry.component.ts | 468 +++++++++++- .../account-facilities.component.ts | 22 +- .../clerk-dashboard.component.ts | 2 +- .../my-requests/my-requests.component.html | 176 +++++ .../my-requests/my-requests.component.scss | 308 ++++++++ .../my-requests/my-requests.component.ts | 234 ++++++ .../photo-signature.component.ts | 42 +- .../standing-instructions.component.ts | 22 +- .../today-activity.component.ts | 22 +- .../withdrawal-edit.component.ts | 31 + .../withdrawal-request.component.html | 459 ++++++++++++ .../withdrawal-request.component.scss | 698 ++++++++++++++++++ .../withdrawal-request.component.ts | 514 +++++++++++++ .../withdrawal-review.component.scss | 102 +++ .../withdrawal-review.component.ts | 145 ++++ src/app/core/shell/shell.component.html | 19 +- src/app/core/shell/shell.component.ts | 14 +- .../withdrawal-requests.component.html | 168 +++++ .../withdrawal-requests.component.scss | 413 +++++++++++ .../withdrawal-requests.component.ts | 220 ++++++ .../checker-inbox-and-tasks.component.html | 12 + src/app/tasks/tasks-routing.module.ts | 17 + 26 files changed, 4822 insertions(+), 248 deletions(-) create mode 100644 src/app/cashier/clerk-withdrawal.service.ts create mode 100644 src/app/clerk/my-requests/my-requests.component.html create mode 100644 src/app/clerk/my-requests/my-requests.component.scss create mode 100644 src/app/clerk/my-requests/my-requests.component.ts create mode 100644 src/app/clerk/withdrawal-edit/withdrawal-edit.component.ts create mode 100644 src/app/clerk/withdrawal-request/withdrawal-request.component.html create mode 100644 src/app/clerk/withdrawal-request/withdrawal-request.component.scss create mode 100644 src/app/clerk/withdrawal-request/withdrawal-request.component.ts create mode 100644 src/app/clerk/withdrawal-review/withdrawal-review.component.scss create mode 100644 src/app/clerk/withdrawal-review/withdrawal-review.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 5dfdcc3a8..c9ed042c9 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -35,6 +35,25 @@ const routes: Routes = [ import('../clerk/clerk-dashboard/clerk-dashboard.component').then((m) => m.ClerkDashboardComponent), data: { title: 'Clerk Dashboard', breadcrumb: 'Clerk Dashboard' } }, + { + path: 'withdrawal-request', + loadComponent: () => + import('../clerk/withdrawal-request/withdrawal-request.component').then( + (m) => m.WithdrawalRequestComponent + ), + data: { title: 'New Withdrawal Request', breadcrumb: 'New Withdrawal Request', mode: 'create' } + }, + { + path: 'withdrawal-request/:id', + loadComponent: () => + import('../clerk/withdrawal-edit/withdrawal-edit.component').then((m) => m.WithdrawalEditComponent), + data: { title: 'Edit Withdrawal Request', breadcrumb: 'Edit Withdrawal Request' } + }, + { + path: 'withdrawal-requests', + loadComponent: () => import('../clerk/my-requests/my-requests.component').then((m) => m.MyRequestsComponent), + data: { title: 'My Withdrawal Requests', breadcrumb: 'My Withdrawal Requests' } + }, { path: 'account-facilities', loadComponent: () => diff --git a/src/app/cashier/clerk-withdrawal.service.ts b/src/app/cashier/clerk-withdrawal.service.ts new file mode 100644 index 000000000..9cd827f0c --- /dev/null +++ b/src/app/cashier/clerk-withdrawal.service.ts @@ -0,0 +1,63 @@ +/** + * 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 Clerk Withdrawal Request maker-checker workflow. */ +@Injectable({ providedIn: 'root' }) +export class ClerkWithdrawalService { + private http = inject(HttpClient); + + submit(data: any): Observable { + return this.http.post('/clerk/withdrawal-requests', data); + } + + update(id: number, data: any): Observable { + return this.http.put(`/clerk/withdrawal-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('/clerk/withdrawal-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('/clerk/withdrawal-requests', { params }); + } + + getById(id: number): Observable { + return this.http.get(`/clerk/withdrawal-requests/${id}`); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/withdrawal-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason }); + } + + getByToken(token: string): Observable { + return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`); + } + + disburse(id: number, denominationData: string, 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/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 6827d7ff6..c6971e13d 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -7,6 +7,22 @@ -->
+ + @if (editMode && editRequest?.rejection_reason) { +
+ + + + + +
+ This request was rejected by the manager. + Reason: {{ editRequest.rejection_reason }} + Update the details below and click “Resubmit for Approval”. +
+
+ } + @if (previousDayUnsettled) {
@@ -186,17 +202,27 @@ Payment instrument
- -
+ +
₹
@@ -220,7 +246,15 @@
- +
@@ -249,6 +283,45 @@
} + + @if (!isClerk && loadingWithdrawal) { +
+
+ Looking up approved withdrawal request… +
+ } + @if (!isClerk && activeWithdrawalRequest) { +
+ check_circle +
+
Approved Withdrawal Request Linked
+
+ Token: {{ activeWithdrawalRequest.tok_no }} + · + Amount: ₹{{ activeWithdrawalRequest.amount | number: '1.2-2' }} + · + Client: {{ activeWithdrawalRequest.client_name }} + · + By Clerk: {{ activeWithdrawalRequest.clerk_name }} + · + Approved by: {{ activeWithdrawalRequest.manager_name || '—' }} +
+
+ After transaction succeeds, this request will be automatically marked DISBURSED. +
+
+ +
+ } + @if (loadingAccount) {
@@ -1057,177 +1130,283 @@
- @for (row of rows; track $index; let i = $index) { -
-
{{ i + 1 }}
-
-
-
- Classification -
-
- - -
-
- - -
-
- - -
-
- - +
+ @for (row of rows; track $index; let i = $index) { +
+
{{ i + 1 }}
+
+
+
+ Classification +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
-
- Account -
-
- - -
-
- - -
-
- -
- - +
+
+ Account +
+
+ + +
+
+ + +
+
+ +
+ + +
-
-
-
- Payment instrument -
-
- -
- ₹ +
+
+ Payment instrument +
+
+ +
+ ₹ + +
+
+
+ + +
+
+ + +
+
+
-
- - -
-
- - -
-
- - -
-
-
-
- Narration -
-
- - +
+
+ Narration +
+
+ + +
-
- } + } +
- }
+ +@if (showRejectDialog) { +
+
+
+ ✕ + Reject Withdrawal Request +
+
+

+ Please provide a reason. The request will be sent back to the clerk for correction and resubmission. +

+ +
+
+ + +
+
+
+} + @if (showConfirmDialog && confirmData) {
@@ -1236,9 +1415,10 @@ 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" > {{ confirmData.type === 'DEPOSIT' ? '⬇' : '⬆' }} - Confirm {{ confirmData.type }} + Confirm {{ confirmData.isDisbursement ? 'CASH DISBURSEMENT' : confirmData.type }}
@@ -1258,39 +1438,69 @@ >{{ confirmData.chequeNo }}
} + @if (confirmData.tokNo) { +
+ Token No.{{ confirmData.tokNo }} +
+ }
-
- Current Balance{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }} -
-
- {{ confirmData.type === 'DEPOSIT' ? 'Deposit' : 'Withdrawal' }} Amount - - {{ confirmData.type === 'DEPOSIT' ? '+' : '−' }}{{ confirmData.symbol - }}{{ confirmData.amount | number: '1.2-2' }} - -
-
- Balance After - - {{ confirmData.symbol }}{{ confirmData.balanceAfter | number: '1.2-2' }} - -
+ + @if (confirmData.isDisbursement) { + +
+ Current Balance + {{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }} +
+
+ Disburse Amount + {{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }} +
+
+ Balance already deducted at manager approval — no further deduction +
+ } @else { +
+ Current Balance{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }} +
+
+ {{ confirmData.type === 'DEPOSIT' ? 'Deposit' : 'Withdrawal' }} Amount + + {{ confirmData.type === 'DEPOSIT' ? '+' : '−' }}{{ confirmData.symbol + }}{{ confirmData.amount | number: '1.2-2' }} + +
+
+ Balance After + + {{ confirmData.symbol }}{{ confirmData.balanceAfter | number: '1.2-2' }} + +
+ }
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 3a7a9ab39..7edb43e45 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -473,6 +473,161 @@ $ws-fld-bd: #c8d8eb; cursor: not-allowed; } } + + &--reject { + background: #fff; + color: #dc2626; + font-weight: 700; + padding: 6px 16px; + border: 1.5px solid #fca5a5; + + &:hover:not(:disabled) { + background: #fef2f2; + border-color: #dc2626; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } +} + +// ── Clerk edit: rejection banner ────────────────────────────────────────────── +.te-reject-banner { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 10px 12px 0; + padding: 12px 16px; + background: #fef2f2; + border: 1px solid #fca5a5; + border-left: 4px solid #dc2626; + border-radius: 8px; + color: #991b1b; + + svg { + flex-shrink: 0; + margin-top: 1px; + color: #dc2626; + } + + div { + display: flex; + flex-direction: column; + gap: 2px; + } + + strong { + font-size: 13px; + font-weight: 700; + } + + span { + font-size: 12.5px; + } + + small { + font-size: 11.5px; + color: #b45309; + margin-top: 2px; + } +} + +// ── Manager review extras ───────────────────────────────────────────────────── +// Fieldset wrapper around the row form — disabled in review mode to make it read-only, +// while staying layout-neutral so the cards render exactly like the clerk view. +.ws-rows-fieldset { + display: contents; + border: 0; + margin: 0; + padding: 0; + min-width: 0; + + &[disabled] { + .ws-card { + cursor: default; + } + + // Keep values fully legible (browsers fade disabled controls by default) + .ws-f__ctl, + .ws-amt-input, + input, + select, + textarea { + opacity: 1; + color: #1e293b; + -webkit-text-fill-color: #1e293b; + background: #f8fafc; + cursor: default; + } + + .ws-seg__btn { + opacity: 1; + } + } +} + +.ws-review-status { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + margin-left: 10px; + + &[data-status='PENDING'] { + background: #fef9c3; + color: #a16207; + } + + &[data-status='APPROVED'] { + background: #dcfce7; + color: #15803d; + } + + &[data-status='REJECTED'] { + background: #fee2e2; + color: #b91c1c; + } +} + +.ws-reject-hint { + margin: 0 0 10px; + font-size: 12.5px; + color: #64748b; +} + +.ws-reject-textarea { + width: 100%; + border: 1.5px solid #e2e8f0; + border-radius: 10px; + padding: 10px 12px; + font-family: inherit; + font-size: 13px; + resize: vertical; + + &:focus { + outline: none; + border-color: #dc2626; + } +} + +.te-btn--danger { + background: #dc2626; + color: #fff; + border-color: #dc2626; + + &:hover:not(:disabled) { + background: #b91c1c; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } } // ── Row cards ──────────────────────────────────────────────────────────────── @@ -736,6 +891,35 @@ select.ws-f__ctl { cursor: pointer; } +input.ws-f__ctl--readonly { + background: #f1f5f9; + color: #475569; + cursor: default; + user-select: none; + letter-spacing: 0.04em; + font-family: 'Courier New', monospace; + font-size: 11px; +} + +input.ws-amt-input--locked { + background: #fef9c3; + color: #713f12; + cursor: not-allowed; + font-weight: 700; +} + +.ws-amt-wrap--locked { + border-color: #fde047 !important; + background: #fefce8; +} + +.ws-amt-locked-badge { + font-size: 10px; + margin-left: 3px; + vertical-align: middle; + cursor: help; +} + // ── Amount field ────────────────────────────────────────────────────────────── .ws-amt-wrap { display: flex; @@ -765,7 +949,6 @@ select.ws-f__ctl { .ws-amt-input { color: #15803d; font-weight: 700; - cursor: not-allowed; } } } @@ -1591,13 +1774,12 @@ select.te-cell { background: #fff7ed !important; } -// Cash payment: amount auto-filled from denomination (disabled style) +// Cash payment: amount highlighted style .te-cell--cash-auto { background: #f0fdf4 !important; border-color: #86efac !important; color: #15803d !important; font-weight: 700 !important; - cursor: not-allowed; } // Denomination stock overdraft badge (closing position header) @@ -2089,6 +2271,10 @@ select.te-cell { &--withdrawal { background: linear-gradient(135deg, #dc2626, #b91c1c); } + + &--disbursement { + background: linear-gradient(135deg, #0284c7, #0369a1); + } } .te-confirm-icon { @@ -2140,6 +2326,25 @@ select.te-cell { margin: 6px 0; } +.te-confirm-disbursed-note { + justify-content: center; + border-bottom: none; + margin-top: 4px; +} + +.te-confirm-disbursed-badge { + display: inline-block; + background: #dcfce7; + color: #166534; + border: 1px solid #86efac; + border-radius: 6px; + padding: 6px 12px; + font-size: 11.5px; + font-weight: 600; + text-align: center; + width: 100%; +} + .te-confirm-actions { display: flex; justify-content: flex-end; @@ -2829,3 +3034,104 @@ select.te-cell { } } } + +// ── Approved Withdrawal Banner ──────────────────────────────────────────────── +.ws-withdrawal-banner { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 10px 16px; + padding: 12px 14px; + background: linear-gradient(135deg, #ecfdf5, #f0fdf4); + border: 1.5px solid #6ee7b7; + border-radius: 10px; + box-shadow: 0 1px 4px rgb(16 185 129 / 12%); + + &--loading { + background: #f8fafc; + border-color: #cbd5e1; + align-items: center; + gap: 10px; + color: #475569; + font-size: 13px; + } + + &__spinner { + width: 18px; + height: 18px; + border: 2px solid #e2e8f0; + border-top-color: #3b82f6; + border-radius: 50%; + animation: ws-spin 0.7s linear infinite; + flex-shrink: 0; + } + + &__icon { + color: #10b981; + font-size: 22px; + width: 22px; + height: 22px; + flex-shrink: 0; + margin-top: 2px; + } + + &__content { + flex: 1; + min-width: 0; + } + + &__title { + font-size: 13px; + font-weight: 700; + color: #065f46; + margin-bottom: 4px; + } + + &__details { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + font-size: 12px; + color: #374151; + } + + &__note { + margin-top: 6px; + font-size: 11px; + color: #6b7280; + } + + &__clear { + background: none; + border: none; + cursor: pointer; + color: #9ca3af; + padding: 2px; + line-height: 1; + display: flex; + align-items: center; + border-radius: 4px; + flex-shrink: 0; + + &:hover { + color: #6b7280; + background: #f1f5f9; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +.ws-wb-sep { + color: #9ca3af; +} + +@keyframes ws-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index d781184b3..93b4a1b2d 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -6,16 +6,20 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { forkJoin, of, Subject } from 'rxjs'; import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { Router } from '@angular/router'; 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 { SettingsService } from '../../settings/settings.service'; interface TxnRow { typ: string; @@ -620,15 +624,37 @@ export class ImagePreviewDialogComponent { imports: [ CommonModule, FormsModule, - MatDialogModule + MatDialogModule, + MatIconModule ] }) export class TransactionEntryComponent implements OnInit, OnDestroy { + @Input() clerkMode = false; + /** When set, the component runs in manager-review mode for that withdrawal request id. */ + @Input() reviewRequestId: number | null = null; + /** When set, the clerk edits & resubmits that (rejected) withdrawal request id. */ + @Input() editRequestId: number | null = null; + + // Manager review state + reviewRequest: any = null; + showRejectDialog = false; + rejectionReason = ''; + + // Clerk edit (rejected resubmit) state + editRequest: any = null; + + // Active approved withdrawal (cashier disburse flow) + activeWithdrawalRequest: any = null; + loadingWithdrawal = false; + private http = inject(HttpClient); private cashierService = inject(CashierService); private authService = inject(AuthenticationService); private dialog = inject(MatDialog); private clerkUiState = inject(ClerkUiStateService); + private clerkWithdrawalService = inject(ClerkWithdrawalService); + private router = inject(Router); + private settingsService = inject(SettingsService); today = ''; rows: TxnRow[] = []; @@ -659,6 +685,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Search token — cancels stale async results private searchToken = 0; + private lastLoadedAcNo = ''; private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>(); cashVaultGlId: number | null = null; @@ -770,6 +797,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.loadCashVaultGl(); + if (this.reviewMode) { + this.loadRequestIntoForm(this.reviewRequestId!, 'review'); + return; + } + + if (this.editMode) { + this.loadRequestIntoForm(this.editRequestId!, 'edit'); + return; + } + if (this.isClerk) { const saved = this.clerkUiState.snapshot.savedAccount; if (saved) { @@ -799,6 +836,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.persistClerkState(); + } + + /** Save the full loaded account into shared clerk state so the facility/photo/SI/activity views can read it. */ + private persistClerkState(): void { if (this.isClerk && this.accountDetails) { this.clerkUiState.saveFullState({ accountDetails: this.accountDetails, @@ -1014,7 +1056,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } addRow(): void { - this.rows.push({ + const row: TxnRow = { typ: '~', br: 1, trTp: '', @@ -1030,7 +1072,45 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { paymentTypeId: null, bankName: '', routingCode: '' - }); + }; + this.rows.push(row); + // Token is assigned by the backend at actual submission time — not pre-generated here + } + + /** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */ + onTokNoBlur(row: TxnRow): void { + if (this.isClerk || !row.tokNo?.trim()) return; + const token = row.tokNo.trim(); + this.loadingWithdrawal = true; + this.activeWithdrawalRequest = null; + this.clerkWithdrawalService + .getByToken(token) + .pipe( + catchError((err: any) => { + this.loadingWithdrawal = false; + const msg: string = + err?.error?.message ?? + err?.error?.errors?.[0]?.defaultUserMessage ?? + `Token '${token}' could not be verified. Please try again.`; + this.showAlert('error', `Token '${token}'`, [msg]); + return of(null); + }) + ) + .subscribe((req: any) => { + this.loadingWithdrawal = false; + if (!req) return; + this.activeWithdrawalRequest = req; + row.acNo = req.account_no ?? ''; + row.acTyp = 'SD'; + row.amount = Number(req.amount) || null; + row.dbCr = 'Dr'; + row.paymentTypeId = req.payment_type_id ?? null; + row.description = req.description ?? ''; + this.selectedRow = row; + if (req.account_no) { + this.loadAccountDetails(req.account_no.trim(), 'SD'); + } + }); } onAcNoBlur(row: TxnRow, index: number): void { @@ -1041,6 +1121,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { onAcNoInput(row: TxnRow, index: number, value: string): void { row.acNo = value; + this.lastLoadedAcNo = ''; this.acNoInput$.next({ row, index, value }); } @@ -1062,15 +1143,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { onRowSelect(row: TxnRow, index: number): void { this.selectedRow = row; - // Only reload if account number changed or nothing is loaded yet - const loadedId = this.accountDetails?.glCode ?? this.accountDetails?.accountNo; - const alreadyLoaded = this.accountDetails && loadedId === row.acNo.trim(); - if (row.acNo.trim() && !alreadyLoaded) { - this.loadAccountDetails(row.acNo.trim(), row.acTyp); + const acNo = row.acNo.trim(); + const alreadyLoaded = this.accountDetails && this.lastLoadedAcNo === acNo; + if (acNo && !alreadyLoaded) { + this.loadAccountDetails(acNo, row.acTyp); } } private loadAccountDetails(acNo: string, acTyp: string): void { + this.lastLoadedAcNo = acNo; this.loadingAccount = true; this.accountDetails = null; this.clientDetails = null; @@ -1389,6 +1470,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (blob) { const url = URL.createObjectURL(blob); this.signatureUrl = url; + // Signature arrives after the main load — refresh shared state so embedded views get it + this.persistClerkState(); } }); } @@ -1405,6 +1488,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Today's transactions — savings uses "content", CA uses "pageItems" this.todayTransactions = todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []); + + // Push full state so embedded facility/photo/SI/activity views (e.g. manager review) can read it + this.persistClerkState(); } ); }); @@ -1594,7 +1680,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } get isClerk(): boolean { - return this.authService.isClerkUser(); + // clerkMode forces the clerk layout (used by the clerk dashboard and the manager review screen) + return this.clerkMode || this.authService.isClerkUser(); + } + + get reviewMode(): boolean { + return this.reviewRequestId != null; + } + + get editMode(): boolean { + return this.editRequestId != null; + } + + /** Only REJECTED requests are editable; PENDING/APPROVED open read-only. */ + get editResubmittable(): boolean { + return this.editMode && this.editRequest?.status === 'REJECTED'; + } + + /** Form is locked in manager review, or when viewing a non-rejected request in edit mode. */ + get formReadOnly(): boolean { + return this.reviewMode || (this.editMode && !this.editResubmittable); } get isNonIndividual(): boolean { @@ -1901,12 +2006,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const row = this.selectedRow; if (!row || !row.acNo.trim() || !this.accountDetails) return; - // Cash payment → amount MUST come from denomination (not manual entry) const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); const isCash = !!selectedPtEarly?.isCashPayment; - const amount = isCash ? this.denominationTotal : (row.amount ?? this.denominationTotal); + // Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination + const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal); - if (isCash && this.denominationTotal <= 0) { + if (isCash && this.denominationTotal <= 0 && !this.clerkMode) { this.showAlert('error', 'Denomination Required', [ 'Cash transaction: Please enter denomination (notes/coins count) first.' ]); @@ -1918,6 +2023,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } const isDeposit = row.dbCr === 'Cr'; + + // Token number is required for all withdrawals (clerk must enter, cashier must enter approved token) + if (!this.reviewMode && !isDeposit && !row.tokNo?.trim()) { + this.showAlert('error', 'Token Number Required', [ + this.isClerk + ? 'Please enter a Token Number (TOK. NO.) for this withdrawal request.' + : 'Please enter the approved Token Number (TOK. NO.) to process this withdrawal.' + ]); + return; + } + + // Cashier direct withdrawal BLOCKED — must use approved maker-checker token + if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) { + this.showAlert('error', 'Direct Withdrawal Not Permitted', [ + 'Withdrawals cannot be processed directly by the cashier.', + 'All withdrawals must go through the Clerk → Manager approval process.', + 'Enter the approved Token Number (TOK. NO.) to disburse cash.' + ]); + return; + } + + // Approved withdrawal: amount/denomination must exactly match + if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) { + const approvedAmt = Number(this.activeWithdrawalRequest.amount); + if (Math.abs(amount - approvedAmt) > 0.005) { + if (isCash) { + this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [ + `Cash denomination total ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })} does not match the approved withdrawal amount ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`, + `Adjust the notes/coins breakdown to total exactly ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.` + ]); + } else { + this.showAlert('error', 'Amount Mismatch — Approved Withdrawal', [ + `Amount has been changed from the approved value.`, + `Approved: ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + `Entered: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + 'Reset the amount to the approved value before proceeding.' + ]); + } + return; + } + } const isWithdrawal = !isDeposit; // ── Validation ───────────────────────────────────────────────────────── @@ -2129,10 +2275,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void { - // ── Confirmation dialog ──────────────────────────────────────────────── - const balanceAfterTxn = isDeposit ? this.currentBalance + amount : this.currentBalance - amount; + // 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; + this.confirmData = { - type: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL', + type: isDisbursement ? 'DISBURSEMENT' : isDeposit ? 'DEPOSIT' : 'WITHDRAWAL', + isDisbursement, accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo, clientName: this.clientName, amount, @@ -2140,7 +2293,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { balanceAfter: balanceAfterTxn, paymentType: selectedPt?.name ?? 'Cash', chequeNo: row.chequeNo || null, - symbol: this.currencySymbol + symbol: this.currencySymbol, + tokNo: this.activeWithdrawalRequest?.tok_no ?? null }; this.showConfirmDialog = true; } @@ -2160,6 +2314,57 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } + // Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction) + if (this.clerkMode && !isDeposit) { + this.submitClerkWithdrawalRequest(row, 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) { + this.showAlert('error', 'Direct Withdrawal Not Permitted', [ + 'Withdrawals must go through the maker-checker approval process.', + 'Enter the approved token number to disburse.' + ]); + return; + } + + // Token-based approved withdrawal → balance already deducted at manager approval + // Do NOT post another savings transaction — just record disbursement + if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) { + this.saving = true; + const denomJson = JSON.stringify(this.denominationCounts); + const req = this.activeWithdrawalRequest; + this.clerkWithdrawalService + .disburse(req.id, denomJson, amount) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.message ?? + err?.error?.errors?.[0]?.defaultUserMessage ?? + 'Disbursement recording failed. Please try again.'; + this.showAlert('error', 'Disbursement Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + this.showAlert('success', 'Cash Disbursed Successfully', [ + `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`, + `Token No: ${req.tok_no} · Client: ${req.client_name}` + ]); + this.activeWithdrawalRequest = null; + this.clearDenominations(); + this.loadOpeningPosition(); + this.loadAccountDetails(row.acNo.trim(), row.acTyp); + } + }); + return; + } + const today = new Date(); const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`; @@ -2224,6 +2429,235 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + // ── Manager review mode ────────────────────────────────────────────────── + + /** Load a withdrawal request and pre-fill the workspace (manager review or clerk edit). */ + private loadRequestIntoForm(id: number, mode: 'review' | 'edit'): void { + this.clerkWithdrawalService + .getById(id) + .pipe(catchError(() => of(null))) + .subscribe((req: any) => { + if (!req) { + this.showAlert('error', 'Request Not Found', ['This withdrawal request could not be loaded.']); + return; + } + if (mode === 'review') { + this.reviewRequest = req; + } else { + this.editRequest = req; + } + + const acTyp = 'SD'; + const row = this.rows[0]; + row.acNo = req.account_no ?? ''; + row.acTyp = acTyp; + row.paymentTypeId = req.payment_type_id ?? null; + row.amount = Number(req.amount) || 0; + row.description = req.description ?? ''; + row.chequeNo = req.cheque_no ?? ''; + row.bankName = req.bank_name ?? ''; + row.tokNo = req.tok_no ?? ''; + row.dbCr = 'Dr'; + this.selectedRow = row; + + try { + this.denominationCounts = req.denomination_data ? JSON.parse(req.denomination_data) : {}; + } catch { + this.denominationCounts = {}; + } + + if (req.account_no) { + this.loadAccountDetails(req.account_no, acTyp); + } + }); + } + + /** Manager approves → backend posts the real savings withdrawal and deducts the balance. */ + approveReview(): void { + if (!this.reviewRequest || this.saving) return; + this.saving = true; + this.clerkWithdrawalService + .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.showAlert('error', 'Approval Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + this.showAlert( + 'success', + 'Withdrawal Approved', + [ + 'The withdrawal has been approved and the balance has been deducted.', + res.txnId ? `Transaction Ref: ${res.txnId}` : '' + ].filter(Boolean) + ); + setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200); + } + }); + } + + openRejectReview(): void { + this.rejectionReason = ''; + this.showRejectDialog = true; + } + + cancelRejectReview(): void { + this.showRejectDialog = false; + this.rejectionReason = ''; + } + + confirmRejectReview(): void { + if (!this.reviewRequest || this.saving) return; + if (!this.rejectionReason.trim()) { + this.showAlert('error', 'Reason Required', ['Please enter a reason for rejecting this request.']); + return; + } + this.saving = true; + this.clerkWithdrawalService + .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.showAlert('error', 'Rejection Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + this.showRejectDialog = false; + if (res) { + this.showAlert('success', 'Withdrawal Rejected', [ + 'The request has been sent back to the clerk for correction.' + ]); + setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200); + } + }); + } + + backToInbox(): void { + this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']); + } + + /** Build the withdrawal-request payload from the current row + loaded account. */ + private buildWithdrawalPayload(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); + + 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, + tokNo: row.tokNo?.trim() || null, + denominationData: JSON.stringify(this.denominationCounts) + }; + } + + /** Clerk edits a rejected request → resubmit (status back to PENDING) for manager approval. */ + resubmitEdit(): void { + if (!this.editRequest || this.saving) return; + const row = this.selectedRow; + if (!row || !this.accountDetails) { + this.showAlert('error', 'Account Required', ['Please load the account before resubmitting.']); + return; + } + const amount = row.amount ?? 0; + if (!amount || amount <= 0) { + this.showAlert('error', 'Amount Required', ['Please enter a valid withdrawal amount.']); + return; + } + + const payload = this.buildWithdrawalPayload(row, amount); + this.saving = true; + this.clerkWithdrawalService + .update(this.editRequest.id, payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Failed to resubmit request. Please try again.'; + this.showAlert('error', 'Resubmit Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + const tok = (this.editRequest as any)?.tok_no ?? row.tokNo?.trim() ?? '—'; + this.showAlert('success', 'Withdrawal Resubmitted', [ + `Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} resubmitted for manager approval.`, + `Token No: ${tok}` + ]); + setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1200); + } + }); + } + + cancelEdit(): void { + this.router.navigate(['/clerk/withdrawal-requests']); + } + + /** Clerk maker-checker: create a PENDING withdrawal request instead of deducting balance directly. */ + private submitClerkWithdrawalRequest(row: TxnRow, amount: number): void { + const payload = this.buildWithdrawalPayload(row, amount); + + this.saving = true; + this.clerkWithdrawalService + .submit(payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Failed to submit withdrawal request. Please try again.'; + this.showAlert('error', 'Request Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + const ref = res.resourceId ?? res.requestId ?? res.id ?? 'N/A'; + const tok = row.tokNo?.trim() ?? '—'; + 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}` + ]); + this.onCancel(); + } + }); + } + onCancel(): void { this.clerkUiState.clear(); this.accountDetails = null; diff --git a/src/app/clerk/account-facilities/account-facilities.component.ts b/src/app/clerk/account-facilities/account-facilities.component.ts index 820f32b99..6f887f97b 100644 --- a/src/app/clerk/account-facilities/account-facilities.component.ts +++ b/src/app/clerk/account-facilities/account-facilities.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, Input, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service'; @@ -44,12 +44,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
}
- + @if (!embedded) { + + }
@if (!accountLoaded) { @@ -63,7 +65,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';

No Account Loaded

Search an account on the Clerk Dashboard first.

- + @if (!embedded) { + + } } @else { @@ -121,6 +125,8 @@ export class AccountFacilitiesComponent implements OnInit { private clerkUiState = inject(ClerkUiStateService); private router = inject(Router); + @Input() embedded = false; + accountLoaded = false; facilities: { label: string; active: boolean }[] = []; diff --git a/src/app/clerk/clerk-dashboard/clerk-dashboard.component.ts b/src/app/clerk/clerk-dashboard/clerk-dashboard.component.ts index bbaf7ff79..ade8faf32 100644 --- a/src/app/clerk/clerk-dashboard/clerk-dashboard.component.ts +++ b/src/app/clerk/clerk-dashboard/clerk-dashboard.component.ts @@ -13,6 +13,6 @@ import { TransactionEntryComponent } from '../../cashier/transaction-entry/trans selector: 'mifosx-clerk-dashboard', standalone: true, imports: [TransactionEntryComponent], - template: `` + template: `` }) export class ClerkDashboardComponent {} diff --git a/src/app/clerk/my-requests/my-requests.component.html b/src/app/clerk/my-requests/my-requests.component.html new file mode 100644 index 000000000..5cec978ec --- /dev/null +++ b/src/app/clerk/my-requests/my-requests.component.html @@ -0,0 +1,176 @@ + + +
+ +
+
+
CLERK · WITHDRAWAL REQUESTS
+

My Withdrawal Requests

+

Track the status of your submitted withdrawal requests. Edit and resubmit rejected ones.

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

No withdrawal requests found.

+ +
+ } @else { + + + + + + + + + + + + + + + + + @for (r of requests; track r.id) { + + + + + + + + + + + + + } + +
#AccountClientAmountPayment ModeStatusSubmittedReviewed ByRejection / CashierAction
{{ r.id }}{{ 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 (r.status === 'DISBURSED') { +
+ {{ r.disbursed_by || '—' }} + {{ formatDateTime(r.disbursed_at) }} +
+ } @else { + — + } +
+ @if (r.status === 'REJECTED') { + + } @else { + + } +
+ + + } +
+
diff --git a/src/app/clerk/my-requests/my-requests.component.scss b/src/app/clerk/my-requests/my-requests.component.scss new file mode 100644 index 000000000..d55f91969 --- /dev/null +++ b/src/app/clerk/my-requests/my-requests.component.scss @@ -0,0 +1,308 @@ +/** + * 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; +} + +.myr-table { + td, + th { + font-size: 13px; + } +} + +.myr-row { + cursor: default; + transition: background 0.1s; + + &:hover { + background: #f8fafc; + } + + &--rejected td { + background: #fff5f5; + } + + &--approved td { + background: #f0fdf4; + } +} + +.myr-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; + } + + &--disbursed { + background: #e0f2fe; + color: #0369a1; + } +} + +.myr-row--disbursed td { + background: #f0f9ff; +} + +.myr-disbursed-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.myr-disbursed-cashier { + font-size: 12px; + font-weight: 600; + color: #0369a1; +} + +.myr-dt { + font-size: 11px; + color: #64748b; +} + +.myr-rejection { + max-width: 200px; +} + +.myr-rejection-text { + font-size: 12px; + color: #991b1b; + font-style: italic; +} + +.myr-none { + color: #94a3b8; +} + +.myr-actions-cell { + white-space: nowrap; +} + +.myr-btn-edit { + font-size: 12px; + height: 30px; + padding: 0 12px; +} + +.myr-btn-view { + font-size: 12px; + height: 30px; + padding: 0 12px; +} + +.myr-th-action { + width: 140px; +} + +.myr-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; + } +} + +.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; +} + +.myr-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-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; + } + } +} diff --git a/src/app/clerk/my-requests/my-requests.component.ts b/src/app/clerk/my-requests/my-requests.component.ts new file mode 100644 index 000000000..abdd5b805 --- /dev/null +++ b/src/app/clerk/my-requests/my-requests.component.ts @@ -0,0 +1,234 @@ +/** + * 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, RouterModule } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +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 { ClerkWithdrawalService } from '../../cashier/clerk-withdrawal.service'; +import { SettingsService } from '../../settings/settings.service'; + +@Component({ + selector: 'mifosx-my-requests', + templateUrl: './my-requests.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + './my-requests.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatIconModule, + MatPaginatorModule, + MatSnackBarModule, + MatNativeDateModule, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule + ] +}) +export class MyRequestsComponent implements OnInit { + private service = inject(ClerkWithdrawalService); + private router = inject(Router); + private snackBar = inject(MatSnackBar); + 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; + + 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: '', label: 'All' }, + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' }, + { value: 'DISBURSED', label: 'Disbursed' } + ]; + } + + 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}`; + } + + newRequest(): void { + this.router.navigate(['/clerk/dashboard']); + } + + editRequest(req: any): void { + this.router.navigate([ + '/clerk/withdrawal-request', + req.id + ]); + } + + viewRequest(req: any): void { + this.router.navigate([ + '/clerk/withdrawal-request', + req.id + ]); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'myr-status--pending', + APPROVED: 'myr-status--approved', + REJECTED: 'myr-status--rejected', + DISBURSED: 'myr-status--disbursed' + }; + return map[status] ?? ''; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending Approval', + APPROVED: 'Approved', + REJECTED: 'Rejected', + DISBURSED: 'Disbursed' + }; + return map[status] ?? status; + } + + formatDate(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); + } catch { + return String(val); + } + } + + 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/clerk/photo-signature/photo-signature.component.ts b/src/app/clerk/photo-signature/photo-signature.component.ts index afdba490c..b088e25be 100644 --- a/src/app/clerk/photo-signature/photo-signature.component.ts +++ b/src/app/clerk/photo-signature/photo-signature.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, Input, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service'; @@ -38,12 +38,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service'; } - + @if (!embedded) { + + } @if (!accountLoaded) { @@ -57,7 +59,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';

No Account Loaded

Search an account on the Clerk Dashboard first.

- + @if (!embedded) { + + } } @else {
@@ -151,6 +155,8 @@ export class PhotoSignatureComponent implements OnInit { private clerkUiState = inject(ClerkUiStateService); private router = inject(Router); + @Input() embedded = false; + accountLoaded = false; photoUrl: string | null = null; signatureUrl: string | null = null; @@ -161,15 +167,17 @@ export class PhotoSignatureComponent implements OnInit { previewLabel = ''; ngOnInit(): void { - const s = this.clerkUiState.snapshot; - this.accountLoaded = s.accountLoaded; - if (s.savedAccount) { - this.photoUrl = s.savedAccount.clientPhotoUrl; - this.signatureUrl = s.savedAccount.signatureUrl; - this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? ''; - const cd = s.savedAccount.clientDetails; - this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : ''; - } + // Subscribe (not snapshot) so a late-arriving signature blob updates the view while embedded + this.clerkUiState.state$.subscribe((s) => { + this.accountLoaded = s.accountLoaded; + if (s.savedAccount) { + this.photoUrl = s.savedAccount.clientPhotoUrl; + this.signatureUrl = s.savedAccount.signatureUrl; + this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? ''; + const cd = s.savedAccount.clientDetails; + this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : ''; + } + }); } preview(src: string, label: string): void { diff --git a/src/app/clerk/standing-instructions/standing-instructions.component.ts b/src/app/clerk/standing-instructions/standing-instructions.component.ts index a346c7a18..b80e50dd2 100644 --- a/src/app/clerk/standing-instructions/standing-instructions.component.ts +++ b/src/app/clerk/standing-instructions/standing-instructions.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, Input, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service'; @@ -44,12 +44,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
} - + @if (!embedded) { + + } @if (!accountLoaded) { @@ -64,7 +66,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';

No Account Loaded

Search an account on the Clerk Dashboard first.

- + @if (!embedded) { + + } } @else if (instructions.length === 0) {
@@ -170,6 +174,8 @@ export class StandingInstructionsComponent implements OnInit { private clerkUiState = inject(ClerkUiStateService); private router = inject(Router); + @Input() embedded = false; + accountLoaded = false; instructions: any[] = []; accountNo = ''; diff --git a/src/app/clerk/today-activity/today-activity.component.ts b/src/app/clerk/today-activity/today-activity.component.ts index 7fdde388e..99f80d95c 100644 --- a/src/app/clerk/today-activity/today-activity.component.ts +++ b/src/app/clerk/today-activity/today-activity.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, Input, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service'; @@ -46,12 +46,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
} - + @if (!embedded) { + + } @if (!accountLoaded) { @@ -66,7 +68,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';

No Account Loaded

Search an account on the Clerk Dashboard first.

- + @if (!embedded) { + + } } @else { @@ -180,6 +184,8 @@ export class TodayActivityComponent implements OnInit { private clerkUiState = inject(ClerkUiStateService); private router = inject(Router); + @Input() embedded = false; + accountLoaded = false; transactions: any[] = []; neftRtgs = 0; diff --git a/src/app/clerk/withdrawal-edit/withdrawal-edit.component.ts b/src/app/clerk/withdrawal-edit/withdrawal-edit.component.ts new file mode 100644 index 000000000..b35be67df --- /dev/null +++ b/src/app/clerk/withdrawal-edit/withdrawal-edit.component.ts @@ -0,0 +1,31 @@ +/** + * 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, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TransactionEntryComponent } from '../../cashier/transaction-entry/transaction-entry.component'; + +/** + * Clerk edit wrapper — renders the full clerk dashboard workspace pre-filled from a rejected + * withdrawal request, editable, with a Resubmit action. + */ +@Component({ + selector: 'mifosx-withdrawal-edit', + standalone: true, + imports: [TransactionEntryComponent], + template: `` +}) +export class WithdrawalEditComponent { + private route = inject(ActivatedRoute); + requestId: number | null = null; + + constructor() { + const id = this.route.snapshot.params['id']; + this.requestId = id != null ? Number(id) : null; + } +} diff --git a/src/app/clerk/withdrawal-request/withdrawal-request.component.html b/src/app/clerk/withdrawal-request/withdrawal-request.component.html new file mode 100644 index 000000000..ceab1bca8 --- /dev/null +++ b/src/app/clerk/withdrawal-request/withdrawal-request.component.html @@ -0,0 +1,459 @@ + + +
+ +
+
+
{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · WITHDRAWAL' }}
+

{{ pageTitle }}

+

{{ pageSubtitle }}

+
+
+ +
+
+ + + @if (pageState === 'success') { +
+ check_circle +
+ @if (isReview) { +

Action completed successfully.

+ } @else { +

Withdrawal request submitted for Bank Manager approval.

+ } +

Redirecting…

+
+
+ } + + @if (pageState !== 'success') { + + @if (isEdit && existingRequest?.status === 'REJECTED') { +
+ cancel +
+ This request was rejected + @if (existingRequest?.rejection_reason) { +

Reason: {{ existingRequest.rejection_reason }}

+ } +

Please update the details below and resubmit for approval.

+
+
+ } + + + @if (isCreate) { + + } + + + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + + @if (pageState === 'searching' && isCreate) { +
+ hourglass_empty +

Loading account…

+
+ } + + + @if (account && (pageState === 'loaded' || pageState === 'submitting' || isEdit || isReview)) { + + } + + + @if (account && (pageState === 'loaded' || pageState === 'submitting' || isEdit || isReview)) { +
+
Transaction Details
+ +
+ + + + + + + + + + + + + + + + + @if (isChequePayment || chequeNo) { + + + + } +
+
+ + + @if (isCashPayment && allDenominations.length > 0) { +
+
+
+ local_atm + Denomination Breakdown +
+ @if (!isReview) { +
+ Total: ₹{{ denominationTotal | number: '1.2-2' }} + @if ((amount || 0) > 0 && denominationTotal > 0) { + @if (denomMatchesAmount) { + check_circle + } @else { + error + } + } +
+ } @else { +
Total: ₹{{ denominationTotal | number: '1.2-2' }}
+ } +
+ + @if (validationErrors['denom']) { +
{{ validationErrors['denom'] }}
+ } + + @if (notes.length > 0) { + +
+ @for (d of notes; track d.id) { +
+ {{ d.label }} + @if (!isReview) { + + + + } @else { + {{ countFor(d) }} + } + ₹{{ subtotalFor(d) | number: '1.0-0' }} +
+ } +
+ } + + @if (coins.length > 0) { + +
+ @for (d of coins; track d.id) { +
+ {{ d.label }} + @if (!isReview) { + + + + } @else { + {{ countFor(d) }} + } + ₹{{ subtotalFor(d) | number: '1.0-0' }} +
+ } +
+ } + + @if (!isReview) { + + } +
+ } + + + @if (activityLog.length > 0) { +
+
+ history + Activity Log +
+
+ @for (entry of activityLog; track $index) { +
+
+
+
{{ activityLabel(entry.action) }}
+
+ by {{ entry.user }} + {{ formatActivityTime(entry.timestamp) }} +
+ @if (entry.note) { +
{{ entry.note }}
+ } +
+
+ } +
+
+ } + + +
+ @if (!isReview) { + + + + } @else { + + + + + } +
+ + + @if (showRejectDialog) { +
+
+
+ cancel + Reject Withdrawal Request +
+
+

Provide a reason for rejection. The clerk will see this reason and can edit and resubmit.

+ +
+ +
+
+ } + } + + + @if (isCreate && pageState === 'idle' && !account) { +
+ account_balance_wallet +

Search for a savings account to start a withdrawal request.

+
+ } + } +
diff --git a/src/app/clerk/withdrawal-request/withdrawal-request.component.scss b/src/app/clerk/withdrawal-request/withdrawal-request.component.scss new file mode 100644 index 000000000..e6b1a6d56 --- /dev/null +++ b/src/app/clerk/withdrawal-request/withdrawal-request.component.scss @@ -0,0 +1,698 @@ +/** + * 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; +} + +// ── Success State ────────────────────────────────────────────────────────── + +.wdr-success { + display: flex; + align-items: center; + gap: 16px; + padding: 24px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 12px; + + &__icon { + font-size: 40px; + width: 40px; + height: 40px; + color: #16a34a; + } + + p { + margin: 0 0 4px; + font-size: 16px; + font-weight: 600; + color: #166534; + } + + &__sub { + font-size: 13px !important; + font-weight: 400 !important; + color: #4ade80 !important; + } +} + +// ── Rejection Banner ────────────────────────────────────────────────────── + +.wdr-rejection-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 20px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 10px; + color: #991b1b; + + mat-icon { + color: #dc2626; + flex-shrink: 0; + margin-top: 2px; + } + + strong { + display: block; + font-size: 14px; + margin-bottom: 4px; + } + + p { + margin: 2px 0; + font-size: 13px; + } +} + +// ── Account Card ────────────────────────────────────────────────────────── + +.wdr-account-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + + &__avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #0f4f3c, #14785a); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + mat-icon { + color: #fff; + font-size: 22px; + } + } + + &__info { + flex: 1; + } + + &__name { + font-size: 16px; + font-weight: 600; + color: #1a1a1a; + } + + &__meta { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; + } + + &__balance { + text-align: right; + flex-shrink: 0; + } + + &__balance-label { + display: block; + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__balance-value { + font-size: 20px; + font-weight: 700; + color: #16a34a; + + &.wdr-balance--low { + color: #dc2626; + } + } +} + +// ── Form Card ───────────────────────────────────────────────────────────── + +.wdr-form-card { + &__title { + font-size: 14px; + font-weight: 700; + color: #0f4f3c; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid #e2e8f0; + } +} + +.wdr-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } + + &__item--wide { + grid-column: 1 / -1; + } +} + +.wdr-required { + color: #dc2626; + margin-left: 2px; +} + +.wdr-amount-input { + display: flex; + align-items: center; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + + &.wdr-input--error { + border-color: #dc2626; + } +} + +.wdr-amount-prefix { + padding: 0 10px; + font-size: 14px; + font-weight: 600; + color: #64748b; + background: #f1f5f9; + height: 36px; + display: flex; + align-items: center; + border-right: 1px solid #e2e8f0; +} + +.wdr-amount-input input { + flex: 1; + border: none; + padding: 0 10px; + height: 36px; + font-size: 15px; + font-weight: 600; + color: #0d1b2a; + font-family: inherit; + outline: none; + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + opacity: 1; + } +} + +.pg-field input.wdr-input--error { + border-color: #dc2626; +} + +.wdr-field-error { + font-size: 11px; + color: #dc2626; + margin-top: 2px; + + &--block { + display: block; + padding: 8px 0 4px; + } +} + +.wdr-balance-after { + font-size: 11px; + color: #16a34a; + margin-top: 3px; + + &--neg { + color: #dc2626; + } +} + +.wdr-readonly-value { + padding: 8px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + color: #0d1b2a; + min-height: 36px; + display: flex; + align-items: center; + + &--amount { + font-size: 18px; + font-weight: 700; + color: #0f4f3c; + } +} + +// ── Denomination Card ───────────────────────────────────────────────────── + +.wdr-denom-card { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid #e2e8f0; + } + + &__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + color: #0f4f3c; + text-transform: uppercase; + letter-spacing: 0.08em; + + mat-icon { + font-size: 18px; + color: #0f4f3c; + } + } + + &__total { + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + color: #0d1b2a; + + &--ok { + color: #16a34a; + } + + &--err { + color: #dc2626; + } + } +} + +.wdr-denom-ok { + font-size: 18px; + color: #16a34a; +} + +.wdr-denom-err { + font-size: 18px; + color: #dc2626; +} + +.wdr-denom-section-label { + font-size: 11px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 12px 0 8px; +} + +.wdr-denom-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; +} + +.wdr-denom-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.wdr-denom-btn { + width: 28px; + height: 28px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 16px; + font-weight: 600; + color: #0f4f3c; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; + + &:hover:not(:disabled) { + background: #f0fdf4; + border-color: #0f4f3c; + } + + &:disabled { + opacity: 0.35; + cursor: default; + } +} + +.wdr-denom-count { + width: 52px; + height: 28px; + text-align: center; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + color: #0d1b2a; + flex-shrink: 0; +} + +.wdr-denom-count-ro { + width: 40px; + text-align: center; + font-size: 14px; + font-weight: 600; + color: #0d1b2a; +} + +.wdr-denom-subtotal { + margin-left: auto; + font-size: 13px; + font-weight: 600; + color: #0f4f3c; +} + +.wdr-denom-footer { + margin-top: 16px; + display: flex; + justify-content: flex-end; +} + +.wdr-denom-clear { + font-size: 12px; + color: #64748b; +} + +// ── Activity Log ────────────────────────────────────────────────────────── + +.wdr-activity-card { + &__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + color: #0f4f3c; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid #e2e8f0; + + mat-icon { + font-size: 18px; + color: #0f4f3c; + } + } +} + +.wdr-activity-timeline { + display: flex; + flex-direction: column; + gap: 0; + padding-left: 20px; + border-left: 2px solid #e2e8f0; +} + +.wdr-activity-item { + position: relative; + padding: 0 0 16px 20px; + + &:last-child { + padding-bottom: 0; + } +} + +.wdr-activity-dot { + position: absolute; + left: -9px; + top: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #cbd5e1; + border: 2px solid #fff; + box-shadow: 0 0 0 2px #cbd5e1; + + .wdr-activity--submitted & { + background: #3b82f6; + box-shadow: 0 0 0 2px #3b82f6; + } + + .wdr-activity--resubmitted & { + background: #f59e0b; + box-shadow: 0 0 0 2px #f59e0b; + } + + .wdr-activity--approved & { + background: #16a34a; + box-shadow: 0 0 0 2px #16a34a; + } + + .wdr-activity--rejected & { + background: #dc2626; + box-shadow: 0 0 0 2px #dc2626; + } +} + +.wdr-activity-action { + font-size: 13px; + font-weight: 600; + color: #0d1b2a; + + .wdr-activity--submitted & { + color: #1d4ed8; + } + + .wdr-activity--resubmitted & { + color: #b45309; + } + + .wdr-activity--approved & { + color: #15803d; + } + + .wdr-activity--rejected & { + color: #b91c1c; + } +} + +.wdr-activity-meta { + display: flex; + gap: 8px; + margin-top: 2px; + font-size: 12px; + color: #64748b; +} + +.wdr-activity-user { + font-weight: 500; +} + +.wdr-activity-note { + margin-top: 4px; + font-size: 12px; + color: #475569; + background: #f8fafc; + padding: 6px 10px; + border-radius: 6px; + border-left: 3px solid #dc2626; +} + +// ── Action Buttons ──────────────────────────────────────────────────────── + +.wdr-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} + +.wdr-btn-submit { + padding: 0 24px; + height: 42px; + font-size: 14px; + font-weight: 600; + + mat-icon { + font-size: 18px; + } +} + +.wdr-btn-approve { + padding: 0 24px; + height: 42px; + font-size: 14px; + font-weight: 600; + background: #16a34a; + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background 0.15s; + + &:hover:not(:disabled) { + background: #15803d; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.wdr-btn-reject { + padding: 0 20px; + height: 42px; + font-size: 14px; + font-weight: 600; + background: #fee2e2; + color: #dc2626; + border: 1px solid #fca5a5; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background 0.15s; + + &:hover:not(:disabled) { + background: #fecaca; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +// ── Reject Dialog Overlay ───────────────────────────────────────────────── + +.wdr-overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 45%); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.wdr-dialog { + background: #fff; + border-radius: 14px; + box-shadow: 0 20px 60px rgb(0 0 0 / 20%); + max-width: 480px; + width: 90%; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 24px; + background: #fef2f2; + border-bottom: 1px solid #fecaca; + font-size: 16px; + font-weight: 700; + color: #991b1b; + + mat-icon { + color: #dc2626; + } + } + + &__body { + padding: 20px 24px; + + p { + font-size: 13px; + color: #64748b; + margin: 0 0 16px; + } + } + + &__footer { + padding: 16px 24px; + display: flex; + gap: 10px; + border-top: 1px solid #f1f5f9; + background: #f8fafc; + } +} + +.wdr-reason-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + color: #0d1b2a; + resize: vertical; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #dc2626; + box-shadow: 0 0 0 3px rgb(220 38 38 / 10%); + } +} + +.wdr-char-count { + font-size: 11px; + color: #94a3b8; + display: block; + text-align: right; + margin-top: 4px; +} + +// ── Idle Hint ───────────────────────────────────────────────────────────── + +.wdr-idle-hint { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + + &__icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #cbd5e1; + margin-bottom: 16px; + } + + p { + color: #94a3b8; + font-size: 14px; + margin: 0; + } +} diff --git a/src/app/clerk/withdrawal-request/withdrawal-request.component.ts b/src/app/clerk/withdrawal-request/withdrawal-request.component.ts new file mode 100644 index 000000000..e7e4f39e6 --- /dev/null +++ b/src/app/clerk/withdrawal-request/withdrawal-request.component.ts @@ -0,0 +1,514 @@ +/** + * 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 { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { CashierService } from '../../cashier/cashier.service'; +import { ClerkWithdrawalService } from '../../cashier/clerk-withdrawal.service'; + +export type WdMode = 'create' | 'edit' | 'review'; + +interface Denomination { + id: number; + value: number; + kind: 'note' | 'coin'; + label: string; +} + +interface ActivityEntry { + timestamp: string; + action: string; + user: string; + note?: string; +} + +type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success'; + +@Component({ + selector: 'mifosx-withdrawal-request', + templateUrl: './withdrawal-request.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + './withdrawal-request.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatIconModule, + MatSnackBarModule + ] +}) +export class WithdrawalRequestComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private http = inject(HttpClient); + private wdService = inject(ClerkWithdrawalService); + private cashierService = inject(CashierService); + private snackBar = inject(MatSnackBar); + + mode: WdMode = 'create'; + requestId: number | null = null; + existingRequest: any = null; + + pageState: PageState = 'idle'; + accountInput = ''; + account: any = null; + errorMsg = ''; + + // Form fields + amount: number | null = null; + txnDate = ''; + paymentTypeId: number | null = null; + description = ''; + chequeNo = ''; + bankName = ''; + tokNo = ''; + + // Denomination + paymentTypes: any[] = []; + notes: Denomination[] = []; + coins: Denomination[] = []; + denominationCounts: Record = {}; + + // Reject dialog + showRejectDialog = false; + rejectionReason = ''; + + // Activity log + activityLog: ActivityEntry[] = []; + + // Validation errors + validationErrors: Record = {}; + + get isCashPayment(): boolean { + if (!this.paymentTypeId) return true; + const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); + return !pt || !!pt.isCashPayment; + } + + get isChequePayment(): boolean { + if (!this.paymentTypeId) return false; + const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); + return pt?.name?.toLowerCase().includes('cheque') || pt?.name?.toLowerCase().includes('check') || false; + } + + get allDenominations(): Denomination[] { + return [ + ...this.notes, + ...this.coins + ]; + } + + get denominationTotal(): number { + return this.allDenominations.reduce((s, d) => s + this.countFor(d) * d.value, 0); + } + + get denomMatchesAmount(): boolean { + return this.denominationTotal === (this.amount || 0); + } + + get balanceAfterWithdrawal(): number { + return (this.account?.balance ?? 0) - (this.amount || 0); + } + + get isFormValid(): boolean { + if (!this.account) return false; + if (!this.amount || this.amount <= 0) return false; + if (!this.txnDate) return false; + if (this.account?.balance != null && (this.amount || 0) > this.account.balance) return false; + if (this.isCashPayment && this.allDenominations.length > 0 && !this.denomMatchesAmount) return false; + return true; + } + + get isReview(): boolean { + return this.mode === 'review'; + } + get isEdit(): boolean { + return this.mode === 'edit'; + } + get isCreate(): boolean { + return this.mode === 'create'; + } + + get submitLabel(): string { + if (this.pageState === 'submitting') return 'Submitting…'; + return this.mode === 'edit' ? 'Resubmit for Approval' : 'Submit for Approval'; + } + + get pageTitle(): string { + if (this.isReview) return 'Review Withdrawal Request'; + if (this.isEdit) return 'Edit Withdrawal Request'; + return 'New Withdrawal Request'; + } + + get pageSubtitle(): string { + if (this.isReview) return "Approve or reject the clerk's withdrawal request."; + if (this.isEdit) return 'Update the rejected request and resubmit for approval.'; + return 'Submit a cash withdrawal request for Bank Manager approval.'; + } + + ngOnInit(): void { + this.mode = (this.route.snapshot.data?.['mode'] as WdMode) ?? 'create'; + const idParam = this.route.snapshot.params?.['id']; + this.requestId = idParam ? Number(idParam) : null; + + const today = new Date(); + this.txnDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + this.cashierService + .getPaymentTypes() + .pipe(catchError(() => of([]))) + .subscribe((types: any[]) => (this.paymentTypes = types || [])); + + this.loadDenominations(); + + if (this.requestId && (this.mode === 'edit' || this.mode === 'review')) { + this.loadRequest(this.requestId); + } + } + + private loadDenominations(): void { + this.cashierService + .getActiveDenominations() + .pipe(catchError(() => of([]))) + .subscribe((list: any[]) => { + const rows: Denomination[] = (list || []).map((d: any) => ({ + id: d.id, + value: d.denomination ?? d.value, + kind: d.type === 'coin' ? 'coin' : 'note', + label: '₹' + (d.denomination ?? d.value) + })); + const order = (a: Denomination, b: Denomination) => b.value - a.value; + this.notes = rows.filter((r) => r.kind === 'note').sort(order); + this.coins = rows.filter((r) => r.kind === 'coin').sort(order); + }); + } + + private loadRequest(id: number): void { + this.pageState = 'searching'; + this.wdService.getById(id).subscribe({ + next: (req: any) => { + this.existingRequest = req; + this.amount = Number(req.amount); + this.txnDate = req.txn_date ? String(req.txn_date).split('T')[0].split(' ')[0] : this.txnDate; + this.paymentTypeId = req.payment_type_id ? Number(req.payment_type_id) : null; + this.description = req.description ?? ''; + this.chequeNo = req.cheque_no ?? ''; + this.bankName = req.bank_name ?? ''; + this.tokNo = req.tok_no ?? ''; + + if (req.denomination_data) { + try { + this.denominationCounts = JSON.parse(req.denomination_data); + } catch { + /* ignore */ + } + } + + if (req.activity_log) { + try { + this.activityLog = JSON.parse(req.activity_log); + } catch { + this.activityLog = []; + } + } + + this.account = { + id: req.savings_account_id ? Number(req.savings_account_id) : null, + accountNo: req.account_no, + clientName: req.client_name, + clientId: req.client_id ? Number(req.client_id) : null, + accountType: req.account_type, + balance: null + }; + this.accountInput = req.account_no; + + // Load live balance from savings account if ID is available + if (this.account.id) { + this.http + .get(`/savingsaccounts/${this.account.id}`) + .pipe(catchError(() => of(null))) + .subscribe((acc: any) => { + if (acc) { + this.account.balance = Number(acc.summary?.availableBalance ?? acc.summary?.accountBalance ?? 0); + } + }); + } + + this.pageState = 'loaded'; + }, + error: () => { + this.snackBar.open('Failed to load withdrawal request.', 'Close', { duration: 5000 }); + this.pageState = 'idle'; + } + }); + } + + searchAccount(): void { + const query = this.accountInput.trim(); + if (!query) return; + this.pageState = 'searching'; + this.account = null; + this.errorMsg = ''; + + const byId$ = /^\d+$/.test(query) + ? this.http.get(`/savingsaccounts/${query}`).pipe(catchError(() => of(null))) + : of(null); + + byId$ + .pipe( + switchMap((acc: any) => { + if (acc && acc.id) return of(acc); + return this.http + .get(`/search?query=${encodeURIComponent(query)}&resource=savingsaccount`) + .pipe(catchError(() => of([]))) + .pipe( + map((results: any[]) => (results || []).find((r: any) => r.entityId)), + switchMap((r: any) => + r?.entityId + ? this.http.get(`/savingsaccounts/${r.entityId}`).pipe(catchError(() => of(null))) + : of(null) + ) + ); + }) + ) + .subscribe({ + next: (acc: any) => { + if (!acc || !acc.id) { + this.errorMsg = 'Account not found. Please check the account number or ID.'; + this.pageState = 'idle'; + return; + } + this.account = { + id: acc.id, + accountNo: acc.accountNo, + clientName: acc.clientName ?? acc.summary?.clientName, + clientId: acc.clientId, + accountType: acc.depositType?.value ?? 'SAVINGS', + balance: Number(acc.summary?.availableBalance ?? acc.summary?.accountBalance ?? 0), + currency: acc.currency?.displaySymbol ?? '₹', + status: acc.status?.value ?? 'Active', + productName: acc.savingsProductName ?? '' + }; + this.pageState = 'loaded'; + } + }); + } + + // ── Denomination helpers ────────────────────────────────────────────────── + + denomKey(d: Denomination): string { + return `${d.kind}:${d.value}`; + } + + countFor(d: Denomination): number { + return this.denominationCounts[this.denomKey(d)] || 0; + } + + subtotalFor(d: Denomination): number { + return this.countFor(d) * d.value; + } + + onDenomChange(d: Denomination, raw: any): void { + this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0)); + } + + bumpDenom(d: Denomination, delta: number): void { + this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta); + } + + clearDenominations(): void { + this.denominationCounts = {}; + } + + // ── Validation ──────────────────────────────────────────────────────────── + + validate(): boolean { + this.validationErrors = {}; + if (!this.account) { + this.validationErrors['account'] = 'Account is required. Please search and select an account.'; + } + if (!this.amount || this.amount <= 0) { + this.validationErrors['amount'] = 'Amount must be greater than zero.'; + } + if (this.account?.balance != null && (this.amount || 0) > this.account.balance) { + this.validationErrors['amount'] = `Insufficient balance. Available: ₹${this.account.balance.toFixed(2)}`; + } + if (!this.txnDate) { + this.validationErrors['txnDate'] = 'Transaction date is required.'; + } + if (this.isCashPayment && this.allDenominations.length > 0 && !this.denomMatchesAmount) { + this.validationErrors['denom'] = + `Denomination total (₹${this.denominationTotal}) does not match amount (₹${this.amount}).`; + } + return Object.keys(this.validationErrors).length === 0; + } + + // ── Actions ──────────────────────────────────────────────────────────────── + + submit(): void { + if (!this.validate() || this.pageState === 'submitting') return; + + const payload = { + accountNo: this.account.accountNo, + accountType: this.account.accountType || 'SAVINGS', + savingsAccountId: this.account.id, + clientId: this.account.clientId, + clientName: this.account.clientName, + amount: this.amount, + txnDate: this.txnDate, + paymentTypeId: this.paymentTypeId || null, + paymentTypeName: this.paymentTypes.find((p) => p.id === this.paymentTypeId)?.name ?? 'Cash', + description: this.description || null, + chequeNo: this.chequeNo || null, + bankName: this.bankName || null, + tokNo: this.tokNo?.trim() || null, + denominationData: JSON.stringify(this.denominationCounts) + }; + + this.pageState = 'submitting'; + + const request$ = + this.mode === 'edit' && this.requestId + ? this.wdService.update(this.requestId, payload) + : this.wdService.submit(payload); + + request$.subscribe({ + next: () => { + this.pageState = 'success'; + const msg = + this.mode === 'edit' ? 'Request resubmitted for approval.' : 'Withdrawal request submitted for approval.'; + this.snackBar.open(msg, 'Close', { duration: 3000 }); + setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1500); + }, + error: (err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'Failed to submit request. Please try again.'; + if (/token/i.test(msg)) { + this.validationErrors['tokNo'] = msg; + } + this.snackBar.open(msg, 'Close', { duration: 8000 }); + this.pageState = 'loaded'; + } + }); + } + + openRejectDialog(): void { + this.rejectionReason = ''; + this.showRejectDialog = true; + } + + cancelReject(): void { + this.showRejectDialog = false; + this.rejectionReason = ''; + } + + approve(): void { + if (!this.requestId || this.pageState === 'submitting') return; + this.pageState = 'submitting'; + this.wdService.approve(this.requestId).subscribe({ + next: (res: any) => { + this.pageState = 'success'; + this.snackBar.open('Withdrawal request approved successfully.', 'Close', { duration: 3000 }); + setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1500); + }, + error: (err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Approval failed. Please try again.'; + this.snackBar.open(msg, 'Close', { duration: 8000 }); + this.pageState = 'loaded'; + } + }); + } + + confirmReject(): void { + if (!this.requestId || !this.rejectionReason.trim() || this.pageState === 'submitting') return; + this.pageState = 'submitting'; + this.wdService.reject(this.requestId, this.rejectionReason.trim()).subscribe({ + next: () => { + this.showRejectDialog = false; + this.pageState = 'success'; + this.snackBar.open('Withdrawal request rejected.', 'Close', { duration: 3000 }); + setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1500); + }, + error: (err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Rejection failed. Please try again.'; + this.snackBar.open(msg, 'Close', { duration: 8000 }); + this.pageState = 'loaded'; + } + }); + } + + goBack(): void { + if (this.isReview) { + this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']); + } else { + this.router.navigate(['/clerk/withdrawal-requests']); + } + } + + // ── Activity log helpers ────────────────────────────────────────────────── + + activityLabel(action: string): string { + const labels: Record = { + SUBMITTED: 'Submitted', + RESUBMITTED: 'Resubmitted', + APPROVED: 'Approved', + REJECTED: 'Rejected' + }; + return labels[action] || action; + } + + activityClass(action: string): string { + const map: Record = { + SUBMITTED: 'wdr-activity--submitted', + RESUBMITTED: 'wdr-activity--resubmitted', + APPROVED: 'wdr-activity--approved', + REJECTED: 'wdr-activity--rejected' + }; + return map[action] || ''; + } + + resolvePaymentTypeName(): string { + if (!this.paymentTypeId) return 'Cash'; + return this.paymentTypes.find((p) => p.id === this.paymentTypeId)?.name ?? 'Cash'; + } + + formatActivityTime(ts: string): string { + try { + const d = new Date(ts); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return ts; + } + } +} diff --git a/src/app/clerk/withdrawal-review/withdrawal-review.component.scss b/src/app/clerk/withdrawal-review/withdrawal-review.component.scss new file mode 100644 index 000000000..0174b535e --- /dev/null +++ b/src/app/clerk/withdrawal-review/withdrawal-review.component.scss @@ -0,0 +1,102 @@ +/** + * 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/. + */ + +.wr { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.wr-body { + flex: 1; +} + +/* Mirrors the clerk dashboard nav (shell .cashier-nav) for identical look & feel */ +.cashier-nav { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + background: #1a2744; + padding: 4px 12px; + border-bottom: 2px solid #0ea5e9; + + &__item { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: #94a3b8; + background: transparent; + border: none; + cursor: pointer; + white-space: nowrap; + transition: + background 0.15s, + color 0.15s; + + &:hover { + background: #223060; + color: #e2e8f0; + } + + &--active { + background: #0ea5e9; + color: #fff; + } + + &--primary { + background: #1353a0; + color: #fff; + font-weight: 700; + + &:hover { + background: #1a6ac8; + } + + &.cashier-nav__item--active { + background: #1353a0; + } + } + + &--sec { + font-size: 11.5px; + padding: 4px 10px; + border: 1px solid rgb(255 255 255 / 12%); + } + } + + &__esc-hint { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 600; + color: #cbd5e1; + cursor: pointer; + padding: 4px 12px; + border-radius: 4px; + border: 1px solid #2a3a5e; + transition: + color 0.15s, + border-color 0.15s; + + &:hover { + color: #e2e8f0; + border-color: #0ea5e9; + } + } + + &--clerk { + border-bottom-color: #e87722; + } +} diff --git a/src/app/clerk/withdrawal-review/withdrawal-review.component.ts b/src/app/clerk/withdrawal-review/withdrawal-review.component.ts new file mode 100644 index 000000000..f9484a948 --- /dev/null +++ b/src/app/clerk/withdrawal-review/withdrawal-review.component.ts @@ -0,0 +1,145 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TransactionEntryComponent } from '../../cashier/transaction-entry/transaction-entry.component'; +import { AccountFacilitiesComponent } from '../account-facilities/account-facilities.component'; +import { PhotoSignatureComponent } from '../photo-signature/photo-signature.component'; +import { StandingInstructionsComponent } from '../standing-instructions/standing-instructions.component'; +import { TodayActivityComponent } from '../today-activity/today-activity.component'; + +type ReviewTab = 'dashboard' | 'facilities' | 'photo' | 'si' | 'activity'; + +/** + * Manager review wrapper — full-screen, self-contained. Renders the same workspace + nav the clerk + * sees, pre-filled from the withdrawal request, with Approve/Reject. The header tabs switch panels + * in-page (no navigation away) and show the reviewed account's data via shared clerk state. + */ +@Component({ + selector: 'mifosx-withdrawal-review', + standalone: true, + imports: [ + CommonModule, + TransactionEntryComponent, + AccountFacilitiesComponent, + PhotoSignatureComponent, + StandingInstructionsComponent, + TodayActivityComponent + ], + template: ` +
+ + +
+ +
+ +
+ @if (activeTab === 'facilities') { + + } + @if (activeTab === 'photo') { + + } + @if (activeTab === 'si') { + + } + @if (activeTab === 'activity') { + + } +
+
+ `, + styleUrl: './withdrawal-review.component.scss' +}) +export class WithdrawalReviewComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + + requestId: number | null = null; + activeTab: ReviewTab = 'dashboard'; + + constructor() { + const id = this.route.snapshot.params['id']; + this.requestId = id != null ? Number(id) : null; + } + + backToInbox(): void { + this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index c2305d279..c707ce78d 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -57,9 +57,9 @@ - - @if (hideShellLayout) { - + + @if (hideShellLayout && !isWithdrawalReviewUrl) { + @if (currentUrl.includes('/clerk/')) { diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 2316adfec..df4d2b9b7 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -16,6 +16,7 @@ import { Route } from '../core/route/route.service'; /** Custom Components */ 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 { 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'; @@ -101,6 +102,11 @@ const routes: Routes = [ resolve: { rejectedAccountsData: GetRejectedAccounts } + }, + { + path: 'withdrawal-requests', + component: WithdrawalRequestsComponent, + data: { title: 'Withdrawal Requests' } } ] }, @@ -116,6 +122,17 @@ const routes: Routes = [ } } ] + }, + { + path: 'checker-inbox-and-tasks/withdrawal-requests', + children: [ + { + path: ':id/review', + loadComponent: () => + import('../clerk/withdrawal-review/withdrawal-review.component').then((m) => m.WithdrawalReviewComponent), + data: { title: 'Review Withdrawal Request', breadcrumb: 'Review Withdrawal' } + } + ] } ]) ];