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:
co-authored by
Claude Sonnet 4.6
parent
de420bb8ab
commit
61cb09f352
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user