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 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-11 15:57:33 +05:30
co-authored by Claude Sonnet 4.6
parent de420bb8ab
commit 61cb09f352
26 changed files with 4822 additions and 248 deletions
@@ -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';
</div>
}
</div>
<button class="af-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
@if (!embedded) {
<button class="af-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
@@ -63,7 +65,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
</div>
<p class="af-empty__title">No Account Loaded</p>
<p class="af-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="af-empty__btn" (click)="goBack()">Go to Dashboard</button>
@if (!embedded) {
<button class="af-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else {
<!-- Progress bar -->
@@ -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 }[] = [];
@@ -13,6 +13,6 @@ import { TransactionEntryComponent } from '../../cashier/transaction-entry/trans
selector: 'mifosx-clerk-dashboard',
standalone: true,
imports: [TransactionEntryComponent],
template: `<mifosx-transaction-entry></mifosx-transaction-entry>`
template: `<mifosx-transaction-entry [clerkMode]="true"></mifosx-transaction-entry>`
})
export class ClerkDashboardComponent {}
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<!-- ── HEADER ── -->
<header class="pg-head">
<div>
<div class="pg-eyebrow">CLERK · WITHDRAWAL REQUESTS</div>
<h1 class="pg-title">My Withdrawal Requests</h1>
<p class="pg-sub">Track the status of your submitted withdrawal requests. Edit and resubmit rejected ones.</p>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">
<mat-icon>add</mat-icon> New Request
</button>
</header>
<!-- ── FILTER ROW ── -->
<div class="pg-filter-row">
<select class="myr-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="pg-fb-sep"></span>
<div class="pg-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="pg-preset-chip"
[class.pg-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="pg-fb-sep"></span>
<div class="pg-date-range">
<div class="pg-date-box">
<span class="pg-date-box__lbl">From</span>
<input
class="pg-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="pg-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="pg-date-range__sep">—</span>
<div class="pg-date-box">
<span class="pg-date-box__lbl">To</span>
<input
class="pg-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="pg-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="myr-btn-refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE CARD ── -->
<div class="pg-card">
@if (loading) {
<div class="pg-empty">Loading requests…</div>
} @else if (loaded && requests.length === 0) {
<div class="pg-empty">
<mat-icon style="font-size: 36px; color: #cbd5e1; display: block; text-align: center">inbox</mat-icon>
<p style="margin-top: 8px; color: #94a3b8">No withdrawal requests found.</p>
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()" style="margin-top: 12px">
<mat-icon>add</mat-icon> Create First Request
</button>
</div>
} @else {
<table class="pg-table myr-table">
<thead>
<tr>
<th>#</th>
<th>Account</th>
<th>Client</th>
<th class="num">Amount</th>
<th>Payment Mode</th>
<th>Status</th>
<th>Submitted</th>
<th>Reviewed By</th>
<th>Rejection / Cashier</th>
<th class="myr-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of requests; track r.id) {
<tr
class="myr-row"
[class.myr-row--pending]="r.status === 'PENDING'"
[class.myr-row--approved]="r.status === 'APPROVED'"
[class.myr-row--rejected]="r.status === 'REJECTED'"
[class.myr-row--disbursed]="r.status === 'DISBURSED'"
>
<td class="cell-mono">{{ r.id }}</td>
<td class="cell-mono">{{ r.account_no }}</td>
<td>{{ r.client_name }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.payment_type_name || 'Cash' }}</td>
<td>
<span class="myr-status" [ngClass]="statusClass(r.status)">
{{ statusLabel(r.status) }}
</span>
</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
<td class="myr-rejection">
@if (r.status === 'REJECTED' && r.rejection_reason) {
<span class="myr-rejection-text" [title]="r.rejection_reason">
{{ r.rejection_reason | slice: 0 : 60 }}{{ r.rejection_reason?.length > 60 ? '…' : '' }}
</span>
} @else if (r.status === 'DISBURSED') {
<div class="myr-disbursed-info">
<span class="myr-disbursed-cashier">{{ r.disbursed_by || '—' }}</span>
<span class="myr-dt">{{ formatDateTime(r.disbursed_at) }}</span>
</div>
} @else {
<span class="myr-none">—</span>
}
</td>
<td class="myr-actions-cell">
@if (r.status === 'REJECTED') {
<button type="button" class="pg-btn pg-btn--primary myr-btn-edit" (click)="editRequest(r)">
<mat-icon>edit</mat-icon> Edit & Resubmit
</button>
} @else {
<button type="button" class="pg-btn myr-btn-view" (click)="viewRequest(r)">
<mat-icon>visibility</mat-icon> View
</button>
}
</td>
</tr>
}
</tbody>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -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;
}
}
}
@@ -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<string, string> = {
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<string, string> = {
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);
}
}
}
@@ -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';
</div>
}
</div>
<button class="ps-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
@if (!embedded) {
<button class="ps-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
@@ -57,7 +59,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
</div>
<p class="ps-empty__title">No Account Loaded</p>
<p class="ps-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="ps-empty__btn" (click)="goBack()">Go to Dashboard</button>
@if (!embedded) {
<button class="ps-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else {
<div class="ps-cards-row">
@@ -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 {
@@ -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';
</div>
}
</div>
<button class="si-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
@if (!embedded) {
<button class="si-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
@@ -64,7 +66,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
</div>
<p class="si-empty__title">No Account Loaded</p>
<p class="si-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="si-empty__btn" (click)="goBack()">Go to Dashboard</button>
@if (!embedded) {
<button class="si-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else if (instructions.length === 0) {
<div class="si-empty">
@@ -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 = '';
@@ -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';
</div>
}
</div>
<button class="ta-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
@if (!embedded) {
<button class="ta-back-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
@@ -66,7 +68,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
</div>
<p class="ta-empty__title">No Account Loaded</p>
<p class="ta-empty__sub">Search an account on the Clerk Dashboard first.</p>
<button class="ta-empty__btn" (click)="goBack()">Go to Dashboard</button>
@if (!embedded) {
<button class="ta-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else {
<!-- AML Compliance cards -->
@@ -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;
@@ -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: `<mifosx-transaction-entry [clerkMode]="true" [editRequestId]="requestId"></mifosx-transaction-entry>`
})
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;
}
}
@@ -0,0 +1,459 @@
<!--
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/.
-->
<div class="pg">
<!-- ── HEADER ── -->
<header class="pg-head">
<div>
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · WITHDRAWAL' }}</div>
<h1 class="pg-title">{{ pageTitle }}</h1>
<p class="pg-sub">{{ pageSubtitle }}</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ isReview ? 'Back to Requests' : 'My Requests' }}
</button>
</div>
</header>
<!-- ── SUCCESS STATE ── -->
@if (pageState === 'success') {
<div class="wdr-success">
<mat-icon class="wdr-success__icon">check_circle</mat-icon>
<div class="wdr-success__text">
@if (isReview) {
<p>Action completed successfully.</p>
} @else {
<p>Withdrawal request submitted for Bank Manager approval.</p>
}
<p class="wdr-success__sub">Redirecting…</p>
</div>
</div>
}
@if (pageState !== 'success') {
<!-- ── REJECTION BANNER (clerk edit mode) ── -->
@if (isEdit && existingRequest?.status === 'REJECTED') {
<div class="wdr-rejection-banner">
<mat-icon>cancel</mat-icon>
<div>
<strong>This request was rejected</strong>
@if (existingRequest?.rejection_reason) {
<p>Reason: {{ existingRequest.rejection_reason }}</p>
}
<p>Please update the details below and resubmit for approval.</p>
</div>
</div>
}
<!-- ── ACCOUNT SEARCH (only in create mode) ── -->
@if (isCreate) {
<div class="sc-search-bar">
<div class="sc-search-bar__input-wrap">
<mat-icon class="sc-search-bar__icon">search</mat-icon>
<input
class="sc-search-bar__input"
type="text"
placeholder="Enter Savings Account ID or Account No."
[(ngModel)]="accountInput"
(keyup.enter)="searchAccount()"
/>
</div>
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="pageState === 'searching' || !accountInput.trim()"
(click)="searchAccount()"
>
<mat-icon>manage_search</mat-icon>
{{ pageState === 'searching' ? 'Searching…' : 'Search Account' }}
</button>
</div>
}
<!-- ── ERROR ── -->
@if (errorMsg) {
<div class="sc-alert sc-alert--error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- ── SEARCHING STATE ── -->
@if (pageState === 'searching' && isCreate) {
<div class="pg-empty">
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
<p>Loading account…</p>
</div>
}
<!-- ── ACCOUNT CARD (shown when account loaded) ── -->
@if (account && (pageState === 'loaded' || pageState === 'submitting' || isEdit || isReview)) {
<div class="wdr-account-card">
<div class="wdr-account-card__avatar">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="wdr-account-card__info">
<div class="wdr-account-card__name">{{ account.clientName }}</div>
<div class="wdr-account-card__meta">
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
<span class="sc-badge sc-badge--product">{{ account.accountType }}</span>
@if (account.status) {
<span class="sc-badge sc-badge--active">{{ account.status }}</span>
}
</div>
</div>
@if (account.balance != null) {
<div class="wdr-account-card__balance">
<span class="wdr-account-card__balance-label">Available Balance</span>
<span class="wdr-account-card__balance-value" [class.wdr-balance--low]="(amount || 0) > account.balance">
{{ account.currency ?? '₹' }}{{ account.balance | number: '1.2-2' }}
</span>
</div>
}
</div>
}
<!-- ── TRANSACTION DETAILS FORM ── -->
@if (account && (pageState === 'loaded' || pageState === 'submitting' || isEdit || isReview)) {
<div class="pg-card wdr-form-card">
<div class="wdr-form-card__title">Transaction Details</div>
<div class="wdr-form-grid">
<!-- Amount -->
<label class="pg-field wdr-form-grid__item wdr-form-grid__item--wide">
<span>Withdrawal Amount <span class="wdr-required">*</span></span>
@if (!isReview) {
<div class="wdr-amount-input">
<span class="wdr-amount-prefix">₹</span>
<input
type="number"
min="1"
step="0.01"
[(ngModel)]="amount"
[class.wdr-input--error]="validationErrors['amount']"
placeholder="0.00"
/>
</div>
@if (validationErrors['amount']) {
<span class="wdr-field-error">{{ validationErrors['amount'] }}</span>
}
@if (amount && amount > 0 && account.balance != null) {
<span class="wdr-balance-after" [class.wdr-balance-after--neg]="balanceAfterWithdrawal < 0">
Balance after: ₹{{ balanceAfterWithdrawal | number: '1.2-2' }}
</span>
}
} @else {
<div class="wdr-readonly-value wdr-readonly-value--amount">₹{{ amount | number: '1.2-2' }}</div>
}
</label>
<!-- Transaction Date -->
<label class="pg-field">
<span>Transaction Date <span class="wdr-required">*</span></span>
@if (!isReview) {
<input type="date" [(ngModel)]="txnDate" [class.wdr-input--error]="validationErrors['txnDate']" />
@if (validationErrors['txnDate']) {
<span class="wdr-field-error">{{ validationErrors['txnDate'] }}</span>
}
} @else {
<div class="wdr-readonly-value">{{ txnDate }}</div>
}
</label>
<!-- Payment Type -->
<label class="pg-field">
<span>Payment Mode</span>
@if (!isReview) {
<select class="pg-select" [(ngModel)]="paymentTypeId">
<option [ngValue]="null">Cash (Default)</option>
@for (pt of paymentTypes; track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
} @else {
<div class="wdr-readonly-value">{{ resolvePaymentTypeName() }}</div>
}
</label>
<!-- Token No -->
<label class="pg-field">
<span>Token No</span>
@if (!isReview) {
<input
type="text"
[(ngModel)]="tokNo"
placeholder="Today's token number"
[class.wdr-input--error]="validationErrors['tokNo']"
/>
@if (validationErrors['tokNo']) {
<span class="wdr-field-error">{{ validationErrors['tokNo'] }}</span>
}
} @else {
<div class="wdr-readonly-value">{{ tokNo || '—' }}</div>
}
</label>
<!-- Description -->
<label class="pg-field wdr-form-grid__item--wide">
<span>Narration / Purpose</span>
@if (!isReview) {
<input
type="text"
[(ngModel)]="description"
placeholder="e.g. Withdrawal for business expenses"
maxlength="200"
/>
} @else {
<div class="wdr-readonly-value">{{ description || '—' }}</div>
}
</label>
<!-- Cheque No (conditional) -->
@if (isChequePayment || chequeNo) {
<label class="pg-field">
<span>Cheque No</span>
@if (!isReview) {
<input type="text" [(ngModel)]="chequeNo" placeholder="Cheque number" />
} @else {
<div class="wdr-readonly-value">{{ chequeNo || '—' }}</div>
}
</label>
<label class="pg-field">
<span>Bank Name</span>
@if (!isReview) {
<input type="text" [(ngModel)]="bankName" placeholder="Bank name" />
} @else {
<div class="wdr-readonly-value">{{ bankName || '—' }}</div>
}
</label>
}
</div>
</div>
<!-- ── DENOMINATION SECTION ── -->
@if (isCashPayment && allDenominations.length > 0) {
<div class="pg-card wdr-denom-card">
<div class="wdr-denom-card__header">
<div class="wdr-denom-card__title">
<mat-icon>local_atm</mat-icon>
<span>Denomination Breakdown</span>
</div>
@if (!isReview) {
<div
class="wdr-denom-card__total"
[class.wdr-denom-card__total--ok]="denomMatchesAmount && (amount || 0) > 0"
[class.wdr-denom-card__total--err]="!denomMatchesAmount && denominationTotal > 0"
>
<span>Total: ₹{{ denominationTotal | number: '1.2-2' }}</span>
@if ((amount || 0) > 0 && denominationTotal > 0) {
@if (denomMatchesAmount) {
<mat-icon class="wdr-denom-ok">check_circle</mat-icon>
} @else {
<mat-icon class="wdr-denom-err">error</mat-icon>
}
}
</div>
} @else {
<div class="wdr-denom-card__total">Total: ₹{{ denominationTotal | number: '1.2-2' }}</div>
}
</div>
@if (validationErrors['denom']) {
<div class="wdr-field-error wdr-field-error--block">{{ validationErrors['denom'] }}</div>
}
@if (notes.length > 0) {
<div class="wdr-denom-section-label">Notes</div>
<div class="wdr-denom-grid">
@for (d of notes; track d.id) {
<div class="wdr-denom-row">
<span class="pg-chip">{{ d.label }}</span>
@if (!isReview) {
<button
type="button"
class="wdr-denom-btn"
(click)="bumpDenom(d, -1)"
[disabled]="countFor(d) === 0"
>
−
</button>
<input
type="number"
min="0"
class="wdr-denom-count"
[value]="countFor(d)"
(change)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" class="wdr-denom-btn" (click)="bumpDenom(d, 1)">+</button>
} @else {
<span class="wdr-denom-count-ro">{{ countFor(d) }}</span>
}
<span class="wdr-denom-subtotal">₹{{ subtotalFor(d) | number: '1.0-0' }}</span>
</div>
}
</div>
}
@if (coins.length > 0) {
<div class="wdr-denom-section-label">Coins</div>
<div class="wdr-denom-grid">
@for (d of coins; track d.id) {
<div class="wdr-denom-row">
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
@if (!isReview) {
<button
type="button"
class="wdr-denom-btn"
(click)="bumpDenom(d, -1)"
[disabled]="countFor(d) === 0"
>
−
</button>
<input
type="number"
min="0"
class="wdr-denom-count"
[value]="countFor(d)"
(change)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" class="wdr-denom-btn" (click)="bumpDenom(d, 1)">+</button>
} @else {
<span class="wdr-denom-count-ro">{{ countFor(d) }}</span>
}
<span class="wdr-denom-subtotal">₹{{ subtotalFor(d) | number: '1.0-0' }}</span>
</div>
}
</div>
}
@if (!isReview) {
<div class="wdr-denom-footer">
<button type="button" class="pg-btn wdr-denom-clear" (click)="clearDenominations()">
<mat-icon>clear_all</mat-icon> Clear All
</button>
</div>
}
</div>
}
<!-- ── ACTIVITY LOG ── -->
@if (activityLog.length > 0) {
<div class="pg-card wdr-activity-card">
<div class="wdr-activity-card__title">
<mat-icon>history</mat-icon>
<span>Activity Log</span>
</div>
<div class="wdr-activity-timeline">
@for (entry of activityLog; track $index) {
<div class="wdr-activity-item" [ngClass]="activityClass(entry.action)">
<div class="wdr-activity-dot"></div>
<div class="wdr-activity-body">
<div class="wdr-activity-action">{{ activityLabel(entry.action) }}</div>
<div class="wdr-activity-meta">
<span class="wdr-activity-user">by {{ entry.user }}</span>
<span class="wdr-activity-time">{{ formatActivityTime(entry.timestamp) }}</span>
</div>
@if (entry.note) {
<div class="wdr-activity-note">{{ entry.note }}</div>
}
</div>
</div>
}
</div>
</div>
}
<!-- ── ACTION BUTTONS ── -->
<div class="wdr-actions">
@if (!isReview) {
<!-- Clerk Submit / Resubmit -->
<button
type="button"
class="pg-btn pg-btn--primary wdr-btn-submit"
(click)="submit()"
[disabled]="pageState === 'submitting'"
>
<mat-icon>send</mat-icon>
{{ submitLabel }}
</button>
<button type="button" class="pg-btn" (click)="goBack()">Cancel</button>
} @else {
<!-- Manager Approve / Reject -->
<button
type="button"
class="pg-btn wdr-btn-approve"
(click)="approve()"
[disabled]="pageState === 'submitting'"
>
<mat-icon>check_circle</mat-icon>
{{ pageState === 'submitting' ? 'Processing…' : 'Approve Withdrawal' }}
</button>
<button
type="button"
class="pg-btn wdr-btn-reject"
(click)="openRejectDialog()"
[disabled]="pageState === 'submitting'"
>
<mat-icon>cancel</mat-icon>
Reject
</button>
<button type="button" class="pg-btn" (click)="goBack()">Back to List</button>
}
</div>
<!-- ── REJECT DIALOG OVERLAY ── -->
@if (showRejectDialog) {
<div class="wdr-overlay" (click)="cancelReject()">
<div class="wdr-dialog" (click)="$event.stopPropagation()">
<div class="wdr-dialog__header">
<mat-icon>cancel</mat-icon>
<span>Reject Withdrawal Request</span>
</div>
<div class="wdr-dialog__body">
<p>Provide a reason for rejection. The clerk will see this reason and can edit and resubmit.</p>
<label class="pg-field">
<span>Rejection Reason <span class="wdr-required">*</span></span>
<textarea
class="wdr-reason-input"
[(ngModel)]="rejectionReason"
rows="4"
placeholder="e.g. Supporting documents missing, amount exceeds limit…"
maxlength="500"
></textarea>
<span class="wdr-char-count">{{ rejectionReason.length }}/500</span>
</label>
</div>
<div class="wdr-dialog__footer">
<button
type="button"
class="pg-btn wdr-btn-reject"
(click)="confirmReject()"
[disabled]="!rejectionReason.trim() || pageState === 'submitting'"
>
<mat-icon>cancel</mat-icon>
Confirm Rejection
</button>
<button type="button" class="pg-btn" (click)="cancelReject()">Cancel</button>
</div>
</div>
</div>
}
}
<!-- ── IDLE STATE (no account yet) ── -->
@if (isCreate && pageState === 'idle' && !account) {
<div class="pg-card wdr-idle-hint">
<mat-icon class="wdr-idle-hint__icon">account_balance_wallet</mat-icon>
<p>Search for a savings account to start a withdrawal request.</p>
</div>
}
}
</div>
@@ -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;
}
}
@@ -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<string, number> = {};
// Reject dialog
showRejectDialog = false;
rejectionReason = '';
// Activity log
activityLog: ActivityEntry[] = [];
// Validation errors
validationErrors: Record<string, string> = {};
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<any>(`/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<any>(`/savingsaccounts/${query}`).pipe(catchError(() => of(null)))
: of(null);
byId$
.pipe(
switchMap((acc: any) => {
if (acc && acc.id) return of(acc);
return this.http
.get<any[]>(`/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<any>(`/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<string, string> = {
SUBMITTED: 'Submitted',
RESUBMITTED: 'Resubmitted',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return labels[action] || action;
}
activityClass(action: string): string {
const map: Record<string, string> = {
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;
}
}
}
@@ -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;
}
}
@@ -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: `
<div class="wr">
<nav class="cashier-nav cashier-nav--clerk">
<button
type="button"
class="cashier-nav__item cashier-nav__item--primary"
[class.cashier-nav__item--active]="activeTab === 'dashboard'"
(click)="activeTab = 'dashboard'"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M14 9h4M14 13h4M5 15h7" />
</svg>
Withdrawal Review
</button>
<button
type="button"
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="activeTab === 'facilities'"
(click)="activeTab = 'facilities'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
</button>
<button
type="button"
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="activeTab === 'photo'"
(click)="activeTab = 'photo'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Photo &amp; Signature
</button>
<button
type="button"
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="activeTab === 'si'"
(click)="activeTab = 'si'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
Standing Instructions
</button>
<button
type="button"
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="activeTab === 'activity'"
(click)="activeTab = 'activity'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Today's Activity
</button>
<span class="cashier-nav__esc-hint" (click)="backToInbox()"> ← Back to Inbox </span>
</nav>
<div class="wr-body">
<!-- Dashboard stays mounted so it loads once and keeps shared state fresh -->
<div [hidden]="activeTab !== 'dashboard'">
<mifosx-transaction-entry [clerkMode]="true" [reviewRequestId]="requestId"></mifosx-transaction-entry>
</div>
@if (activeTab === 'facilities') {
<mifosx-account-facilities [embedded]="true"></mifosx-account-facilities>
}
@if (activeTab === 'photo') {
<mifosx-photo-signature [embedded]="true"></mifosx-photo-signature>
}
@if (activeTab === 'si') {
<mifosx-standing-instructions [embedded]="true"></mifosx-standing-instructions>
}
@if (activeTab === 'activity') {
<mifosx-today-activity [embedded]="true"></mifosx-today-activity>
}
</div>
</div>
`,
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']);
}
}