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
@@ -35,6 +35,25 @@ const routes: Routes = [
|
||||
import('../clerk/clerk-dashboard/clerk-dashboard.component').then((m) => m.ClerkDashboardComponent),
|
||||
data: { title: 'Clerk Dashboard', breadcrumb: 'Clerk Dashboard' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-request',
|
||||
loadComponent: () =>
|
||||
import('../clerk/withdrawal-request/withdrawal-request.component').then(
|
||||
(m) => m.WithdrawalRequestComponent
|
||||
),
|
||||
data: { title: 'New Withdrawal Request', breadcrumb: 'New Withdrawal Request', mode: 'create' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-request/:id',
|
||||
loadComponent: () =>
|
||||
import('../clerk/withdrawal-edit/withdrawal-edit.component').then((m) => m.WithdrawalEditComponent),
|
||||
data: { title: 'Edit Withdrawal Request', breadcrumb: 'Edit Withdrawal Request' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-requests',
|
||||
loadComponent: () => import('../clerk/my-requests/my-requests.component').then((m) => m.MyRequestsComponent),
|
||||
data: { title: 'My Withdrawal Requests', breadcrumb: 'My Withdrawal Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkWithdrawalService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/withdrawal-requests', data);
|
||||
}
|
||||
|
||||
update(id: number, data: any): Observable<any> {
|
||||
return this.http.put(`/clerk/withdrawal-requests/${id}`, data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/withdrawal-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/withdrawal-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/withdrawal-requests/${id}`);
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/withdrawal-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string, transactionAmount?: number): Observable<any> {
|
||||
return this.http.post(`/clerk/withdrawal-requests/${id}/disburse`, {
|
||||
denominationData,
|
||||
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,22 @@
|
||||
-->
|
||||
|
||||
<div class="te-root">
|
||||
<!-- ── Clerk edit: rejection reason banner ───────────────────────────── -->
|
||||
@if (editMode && editRequest?.rejection_reason) {
|
||||
<div class="te-reject-banner">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong>This request was rejected by the manager.</strong>
|
||||
<span>Reason: {{ editRequest.rejection_reason }}</span>
|
||||
<small>Update the details below and click “Resubmit for Approval”.</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Cashier Day-Status Blocking Banners ───────────────────────────── -->
|
||||
@if (previousDayUnsettled) {
|
||||
<div class="te-day-block te-day-block--danger">
|
||||
@@ -186,17 +202,27 @@
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Transaction Amount">AMOUNT</label>
|
||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row">
|
||||
<label class="ws-f__lbl" title="Transaction Amount">
|
||||
AMOUNT
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
</label>
|
||||
<div
|
||||
class="ws-amt-wrap"
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="isCashPayment && selectedRow === row ? denominationTotal || '' : (row.amount ?? '')"
|
||||
[disabled]="isCashPayment && selectedRow === row"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +246,15 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl" title="Token Number">TOK. NO.</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.tokNo" (click)="$event.stopPropagation()" />
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.tokNo"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
placeholder="Enter token"
|
||||
(blur)="onTokNoBlur(row)"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,6 +283,45 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
|
||||
@if (!isClerk && loadingWithdrawal) {
|
||||
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
|
||||
<div class="ws-withdrawal-banner__spinner"></div>
|
||||
<span>Looking up approved withdrawal request…</span>
|
||||
</div>
|
||||
}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<div class="ws-withdrawal-banner">
|
||||
<mat-icon class="ws-withdrawal-banner__icon">check_circle</mat-icon>
|
||||
<div class="ws-withdrawal-banner__content">
|
||||
<div class="ws-withdrawal-banner__title">Approved Withdrawal Request Linked</div>
|
||||
<div class="ws-withdrawal-banner__details">
|
||||
<span
|
||||
>Token: <strong>{{ activeWithdrawalRequest.tok_no }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span
|
||||
>Amount: <strong>₹{{ activeWithdrawalRequest.amount | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span
|
||||
>Client: <strong>{{ activeWithdrawalRequest.client_name }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span>By Clerk: {{ activeWithdrawalRequest.clerk_name }}</span>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span>Approved by: {{ activeWithdrawalRequest.manager_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="ws-withdrawal-banner__note">
|
||||
After transaction succeeds, this request will be automatically marked <strong>DISBURSED</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeWithdrawalRequest = null" title="Unlink">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
|
||||
@if (loadingAccount) {
|
||||
<div class="te-loading">
|
||||
@@ -1057,177 +1130,283 @@
|
||||
</div>
|
||||
|
||||
<div class="ws-rows">
|
||||
@for (row of rows; track $index; let i = $index) {
|
||||
<div class="ws-card" [class.ws-card--active]="selectedRow === row" (click)="onRowSelect(row, i)">
|
||||
<div class="ws-card__badge">{{ i + 1 }}</div>
|
||||
<div class="ws-card__body">
|
||||
<div class="ws-group-row">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Classification</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--type">
|
||||
<label class="ws-f__lbl">TYPE</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||
<option value="~">~</option>
|
||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
||||
<option value="Cheque">Cheque</option>
|
||||
<option value="Disbursement">Disbursement</option>
|
||||
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
|
||||
<option value="DD">DD (Demand Draft)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.paymentTypeId"
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptions; track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
<fieldset class="ws-rows-fieldset" [disabled]="formReadOnly">
|
||||
@for (row of rows; track $index; let i = $index) {
|
||||
<div class="ws-card" [class.ws-card--active]="selectedRow === row" (click)="onRowSelect(row, i)">
|
||||
<div class="ws-card__badge">{{ i + 1 }}</div>
|
||||
<div class="ws-card__body">
|
||||
<div class="ws-group-row">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Classification</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--type">
|
||||
<label class="ws-f__lbl">TYPE</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||
<option value="~">~</option>
|
||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
||||
<option value="Cheque">Cheque</option>
|
||||
<option value="Disbursement">Disbursement</option>
|
||||
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
|
||||
<option value="DD">DD (Demand Draft)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.paymentTypeId"
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptions; track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-vdivider"></div>
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Account</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--acno">
|
||||
<label class="ws-f__lbl">A/C NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
[value]="row.acNo"
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
<label class="ws-f__lbl">ECS A/C NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.ecsAcNo" placeholder="ECS account" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--dbcr">
|
||||
<label class="ws-f__lbl">DR./CR.</label>
|
||||
<div class="ws-seg">
|
||||
<button
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="row.acTyp === 'GL'"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--cr"
|
||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
||||
>
|
||||
CR
|
||||
</button>
|
||||
<div class="ws-vdivider"></div>
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Account</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--acno">
|
||||
<label class="ws-f__lbl">A/C NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
[value]="row.acNo"
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
<label class="ws-f__lbl">ECS A/C NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
[(ngModel)]="row.ecsAcNo"
|
||||
placeholder="ECS account"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--dbcr">
|
||||
<label class="ws-f__lbl">DR./CR.</label>
|
||||
<div class="ws-seg">
|
||||
<button
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="row.acTyp === 'GL'"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--cr"
|
||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
||||
>
|
||||
CR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">AMOUNT</label>
|
||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">
|
||||
AMOUNT
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
</label>
|
||||
<div
|
||||
class="ws-amt-wrap"
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl">TOK. NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="
|
||||
isCashPayment && selectedRow === row ? denominationTotal || '' : (row.amount ?? '')
|
||||
"
|
||||
[disabled]="isCashPayment && selectedRow === row"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
placeholder="0.00"
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.tokNo"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
placeholder="Enter token"
|
||||
(blur)="onTokNoBlur(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl">TOK. NO.</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.tokNo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-vdivider"></div>
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Narration</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--desc">
|
||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
||||
<div class="ws-vdivider"></div>
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Narration</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--desc">
|
||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons footer -->
|
||||
<div class="ws-footer">
|
||||
<div class="ws-footer__stats">
|
||||
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="onExit()">Exit</button>
|
||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onCancel()">Cancel</button>
|
||||
<button class="ws-btn ws-btn--ghost" type="button">Drop</button>
|
||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
||||
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
|
||||
@if (reviewMode) {
|
||||
<div class="ws-footer">
|
||||
<div class="ws-footer__stats">
|
||||
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="backToInbox()">
|
||||
← Back to Inbox
|
||||
</button>
|
||||
@if (reviewRequest) {
|
||||
<span class="ws-review-status" [attr.data-status]="reviewRequest.status">
|
||||
{{ reviewRequest.status }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-footer__actions">
|
||||
@if (reviewRequest?.status === 'PENDING') {
|
||||
<button class="ws-btn ws-btn--reject" type="button" (click)="openRejectReview()" [disabled]="saving">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
Reject
|
||||
</button>
|
||||
<button class="ws-btn ws-btn--post" type="button" (click)="approveReview()" [disabled]="saving">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{{ saving ? 'Processing…' : 'Approve & Post' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-footer__actions">
|
||||
<button class="ws-btn ws-btn--post" type="button" (click)="onSave()" [disabled]="saving">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{{ saving ? 'Posting…' : 'Save' }}
|
||||
</button>
|
||||
} @else if (editMode) {
|
||||
<div class="ws-footer">
|
||||
<div class="ws-footer__stats">
|
||||
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="cancelEdit()">
|
||||
← My Requests
|
||||
</button>
|
||||
@if (editRequest) {
|
||||
<span class="ws-review-status" [attr.data-status]="editRequest.status">
|
||||
{{ editResubmittable ? 'EDITING REJECTED' : editRequest.status }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-footer__actions">
|
||||
@if (editResubmittable) {
|
||||
<button class="ws-btn ws-btn--post" type="button" (click)="resubmitEdit()" [disabled]="saving">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{{ saving ? 'Resubmitting…' : 'Resubmit for Approval' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ws-footer">
|
||||
<div class="ws-footer__stats">
|
||||
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="onExit()">Exit</button>
|
||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onCancel()">Cancel</button>
|
||||
<button class="ws-btn ws-btn--ghost" type="button">Drop</button>
|
||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
||||
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
|
||||
</div>
|
||||
<div class="ws-footer__actions">
|
||||
<button class="ws-btn ws-btn--post" type="button" (click)="onSave()" [disabled]="saving">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{{ saving ? 'Posting…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── REJECT DIALOG (manager review) ───────────────────────────────────── -->
|
||||
@if (showRejectDialog) {
|
||||
<div class="te-confirm-overlay" (click)="cancelRejectReview()">
|
||||
<div class="te-confirm-box" (click)="$event.stopPropagation()">
|
||||
<div class="te-confirm-header te-confirm-header--withdrawal">
|
||||
<span class="te-confirm-icon">✕</span>
|
||||
<span>Reject Withdrawal Request</span>
|
||||
</div>
|
||||
<div class="te-confirm-body">
|
||||
<p class="ws-reject-hint">
|
||||
Please provide a reason. The request will be sent back to the clerk for correction and resubmission.
|
||||
</p>
|
||||
<textarea
|
||||
class="ws-reject-textarea"
|
||||
[(ngModel)]="rejectionReason"
|
||||
rows="4"
|
||||
placeholder="e.g. Amount does not match the withdrawal slip; please verify and resubmit."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="te-confirm-actions">
|
||||
<button class="te-btn" type="button" (click)="cancelRejectReview()">Cancel</button>
|
||||
<button class="te-btn te-btn--danger" type="button" [disabled]="saving" (click)="confirmRejectReview()">
|
||||
{{ saving ? 'Rejecting…' : 'Confirm Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── CONFIRMATION DIALOG ──────────────────────────────────────────────── -->
|
||||
@if (showConfirmDialog && confirmData) {
|
||||
<div class="te-confirm-overlay" (click)="showConfirmDialog = false">
|
||||
@@ -1236,9 +1415,10 @@
|
||||
class="te-confirm-header"
|
||||
[class.te-confirm-header--deposit]="confirmData.type === 'DEPOSIT'"
|
||||
[class.te-confirm-header--withdrawal]="confirmData.type === 'WITHDRAWAL'"
|
||||
[class.te-confirm-header--disbursement]="confirmData.isDisbursement"
|
||||
>
|
||||
<span class="te-confirm-icon">{{ confirmData.type === 'DEPOSIT' ? '⬇' : '⬆' }}</span>
|
||||
<span>Confirm {{ confirmData.type }}</span>
|
||||
<span>Confirm {{ confirmData.isDisbursement ? 'CASH DISBURSEMENT' : confirmData.type }}</span>
|
||||
</div>
|
||||
<div class="te-confirm-body">
|
||||
<div class="te-confirm-row">
|
||||
@@ -1258,39 +1438,69 @@
|
||||
><span class="te-confirm-val te-mono">{{ confirmData.chequeNo }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (confirmData.tokNo) {
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Token No.</span
|
||||
><span class="te-confirm-val te-mono"
|
||||
><strong>{{ confirmData.tokNo }}</strong></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="te-confirm-divider"></div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Current Balance</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-confirm-row te-confirm-row--amount">
|
||||
<span class="te-confirm-label">{{ confirmData.type === 'DEPOSIT' ? 'Deposit' : 'Withdrawal' }} Amount</span>
|
||||
<span
|
||||
class="te-confirm-val"
|
||||
[class.te-cr]="confirmData.type === 'DEPOSIT'"
|
||||
[class.te-dr]="confirmData.type === 'WITHDRAWAL'"
|
||||
>
|
||||
{{ confirmData.type === 'DEPOSIT' ? '+' : '−' }}{{ confirmData.symbol
|
||||
}}{{ confirmData.amount | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="te-confirm-row te-confirm-row--after">
|
||||
<span class="te-confirm-label">Balance After</span>
|
||||
<span
|
||||
class="te-confirm-val"
|
||||
[class.te-cr]="confirmData.balanceAfter >= 0"
|
||||
[class.te-dr]="confirmData.balanceAfter < 0"
|
||||
>
|
||||
<strong>{{ confirmData.symbol }}{{ confirmData.balanceAfter | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (confirmData.isDisbursement) {
|
||||
<!-- Token-based disbursement: balance was already deducted at manager approval -->
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Current Balance</span>
|
||||
<span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-confirm-row te-confirm-row--amount">
|
||||
<span class="te-confirm-label">Disburse Amount</span>
|
||||
<span class="te-confirm-val te-cr">{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-confirm-row te-confirm-disbursed-note">
|
||||
<span class="te-confirm-disbursed-badge"
|
||||
>Balance already deducted at manager approval — no further deduction</span
|
||||
>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Current Balance</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-confirm-row te-confirm-row--amount">
|
||||
<span class="te-confirm-label">{{ confirmData.type === 'DEPOSIT' ? 'Deposit' : 'Withdrawal' }} Amount</span>
|
||||
<span
|
||||
class="te-confirm-val"
|
||||
[class.te-cr]="confirmData.type === 'DEPOSIT'"
|
||||
[class.te-dr]="confirmData.type === 'WITHDRAWAL'"
|
||||
>
|
||||
{{ confirmData.type === 'DEPOSIT' ? '+' : '−' }}{{ confirmData.symbol
|
||||
}}{{ confirmData.amount | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="te-confirm-row te-confirm-row--after">
|
||||
<span class="te-confirm-label">Balance After</span>
|
||||
<span
|
||||
class="te-confirm-val"
|
||||
[class.te-cr]="confirmData.balanceAfter >= 0"
|
||||
[class.te-dr]="confirmData.balanceAfter < 0"
|
||||
>
|
||||
<strong>{{ confirmData.symbol }}{{ confirmData.balanceAfter | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="te-confirm-actions">
|
||||
<button class="te-btn" type="button" (click)="showConfirmDialog = false">Cancel</button>
|
||||
<button class="te-btn te-btn--primary" type="button" [disabled]="saving" (click)="confirmSubmit()">
|
||||
{{ saving ? 'Posting…' : 'Confirm ' + confirmData.type }}
|
||||
{{
|
||||
saving ? 'Posting…' : confirmData.isDisbursement ? 'Confirm DISBURSEMENT' : 'Confirm ' + confirmData.type
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -473,6 +473,161 @@ $ws-fld-bd: #c8d8eb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
padding: 6px 16px;
|
||||
border: 1.5px solid #fca5a5;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clerk edit: rejection banner ──────────────────────────────────────────────
|
||||
.te-reject-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 10px 12px 0;
|
||||
padding: 12px 16px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 8px;
|
||||
color: #991b1b;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11.5px;
|
||||
color: #b45309;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manager review extras ─────────────────────────────────────────────────────
|
||||
// Fieldset wrapper around the row form — disabled in review mode to make it read-only,
|
||||
// while staying layout-neutral so the cards render exactly like the clerk view.
|
||||
.ws-rows-fieldset {
|
||||
display: contents;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
&[disabled] {
|
||||
.ws-card {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Keep values fully legible (browsers fade disabled controls by default)
|
||||
.ws-f__ctl,
|
||||
.ws-amt-input,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
opacity: 1;
|
||||
color: #1e293b;
|
||||
-webkit-text-fill-color: #1e293b;
|
||||
background: #f8fafc;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ws-seg__btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ws-review-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
margin-left: 10px;
|
||||
|
||||
&[data-status='PENDING'] {
|
||||
background: #fef9c3;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
&[data-status='APPROVED'] {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&[data-status='REJECTED'] {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.ws-reject-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.ws-reject-textarea {
|
||||
width: 100%;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.te-btn--danger {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
border-color: #dc2626;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row cards ────────────────────────────────────────────────────────────────
|
||||
@@ -736,6 +891,35 @@ select.ws-f__ctl {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input.ws-f__ctl--readonly {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
input.ws-amt-input--locked {
|
||||
background: #fef9c3;
|
||||
color: #713f12;
|
||||
cursor: not-allowed;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ws-amt-wrap--locked {
|
||||
border-color: #fde047 !important;
|
||||
background: #fefce8;
|
||||
}
|
||||
|
||||
.ws-amt-locked-badge {
|
||||
font-size: 10px;
|
||||
margin-left: 3px;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// ── Amount field ──────────────────────────────────────────────────────────────
|
||||
.ws-amt-wrap {
|
||||
display: flex;
|
||||
@@ -765,7 +949,6 @@ select.ws-f__ctl {
|
||||
.ws-amt-input {
|
||||
color: #15803d;
|
||||
font-weight: 700;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1591,13 +1774,12 @@ select.te-cell {
|
||||
background: #fff7ed !important;
|
||||
}
|
||||
|
||||
// Cash payment: amount auto-filled from denomination (disabled style)
|
||||
// Cash payment: amount highlighted style
|
||||
.te-cell--cash-auto {
|
||||
background: #f0fdf4 !important;
|
||||
border-color: #86efac !important;
|
||||
color: #15803d !important;
|
||||
font-weight: 700 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Denomination stock overdraft badge (closing position header)
|
||||
@@ -2089,6 +2271,10 @@ select.te-cell {
|
||||
&--withdrawal {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
}
|
||||
|
||||
&--disbursement {
|
||||
background: linear-gradient(135deg, #0284c7, #0369a1);
|
||||
}
|
||||
}
|
||||
|
||||
.te-confirm-icon {
|
||||
@@ -2140,6 +2326,25 @@ select.te-cell {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.te-confirm-disbursed-note {
|
||||
justify-content: center;
|
||||
border-bottom: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.te-confirm-disbursed-badge {
|
||||
display: inline-block;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.te-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -2829,3 +3034,104 @@ select.te-cell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approved Withdrawal Banner ────────────────────────────────────────────────
|
||||
.ws-withdrawal-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 10px 16px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, #ecfdf5, #f0fdf4);
|
||||
border: 1.5px solid #6ee7b7;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px rgb(16 185 129 / 12%);
|
||||
|
||||
&--loading {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: ws-spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #10b981;
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #065f46;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__clear {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
padding: 2px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: #6b7280;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ws-wb-sep {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes ws-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { forkJoin, of, Subject } from 'rxjs';
|
||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router } from '@angular/router';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -620,15 +624,37 @@ export class ImagePreviewDialogComponent {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule
|
||||
MatDialogModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
@Input() clerkMode = false;
|
||||
/** When set, the component runs in manager-review mode for that withdrawal request id. */
|
||||
@Input() reviewRequestId: number | null = null;
|
||||
/** When set, the clerk edits & resubmits that (rejected) withdrawal request id. */
|
||||
@Input() editRequestId: number | null = null;
|
||||
|
||||
// Manager review state
|
||||
reviewRequest: any = null;
|
||||
showRejectDialog = false;
|
||||
rejectionReason = '';
|
||||
|
||||
// Clerk edit (rejected resubmit) state
|
||||
editRequest: any = null;
|
||||
|
||||
// Active approved withdrawal (cashier disburse flow)
|
||||
activeWithdrawalRequest: any = null;
|
||||
loadingWithdrawal = false;
|
||||
|
||||
private http = inject(HttpClient);
|
||||
private cashierService = inject(CashierService);
|
||||
private authService = inject(AuthenticationService);
|
||||
private dialog = inject(MatDialog);
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
today = '';
|
||||
rows: TxnRow[] = [];
|
||||
@@ -659,6 +685,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Search token — cancels stale async results
|
||||
private searchToken = 0;
|
||||
private lastLoadedAcNo = '';
|
||||
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
||||
cashVaultGlId: number | null = null;
|
||||
|
||||
@@ -770,6 +797,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.loadCashVaultGl();
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editMode) {
|
||||
this.loadRequestIntoForm(this.editRequestId!, 'edit');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isClerk) {
|
||||
const saved = this.clerkUiState.snapshot.savedAccount;
|
||||
if (saved) {
|
||||
@@ -799,6 +836,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.persistClerkState();
|
||||
}
|
||||
|
||||
/** Save the full loaded account into shared clerk state so the facility/photo/SI/activity views can read it. */
|
||||
private persistClerkState(): void {
|
||||
if (this.isClerk && this.accountDetails) {
|
||||
this.clerkUiState.saveFullState({
|
||||
accountDetails: this.accountDetails,
|
||||
@@ -1014,7 +1056,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
addRow(): void {
|
||||
this.rows.push({
|
||||
const row: TxnRow = {
|
||||
typ: '~',
|
||||
br: 1,
|
||||
trTp: '',
|
||||
@@ -1030,7 +1072,45 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
paymentTypeId: null,
|
||||
bankName: '',
|
||||
routingCode: ''
|
||||
});
|
||||
};
|
||||
this.rows.push(row);
|
||||
// Token is assigned by the backend at actual submission time — not pre-generated here
|
||||
}
|
||||
|
||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||
onTokNoBlur(row: TxnRow): void {
|
||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||
const token = row.tokNo.trim();
|
||||
this.loadingWithdrawal = true;
|
||||
this.activeWithdrawalRequest = null;
|
||||
this.clerkWithdrawalService
|
||||
.getByToken(token)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
err?.error?.message ??
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
`Token '${token}' could not be verified. Please try again.`;
|
||||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((req: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
if (!req) return;
|
||||
this.activeWithdrawalRequest = req;
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = 'SD';
|
||||
row.amount = Number(req.amount) || null;
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = req.payment_type_id ?? null;
|
||||
row.description = req.description ?? '';
|
||||
this.selectedRow = row;
|
||||
if (req.account_no) {
|
||||
this.loadAccountDetails(req.account_no.trim(), 'SD');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAcNoBlur(row: TxnRow, index: number): void {
|
||||
@@ -1041,6 +1121,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
onAcNoInput(row: TxnRow, index: number, value: string): void {
|
||||
row.acNo = value;
|
||||
this.lastLoadedAcNo = '';
|
||||
this.acNoInput$.next({ row, index, value });
|
||||
}
|
||||
|
||||
@@ -1062,15 +1143,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
onRowSelect(row: TxnRow, index: number): void {
|
||||
this.selectedRow = row;
|
||||
// Only reload if account number changed or nothing is loaded yet
|
||||
const loadedId = this.accountDetails?.glCode ?? this.accountDetails?.accountNo;
|
||||
const alreadyLoaded = this.accountDetails && loadedId === row.acNo.trim();
|
||||
if (row.acNo.trim() && !alreadyLoaded) {
|
||||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||||
const acNo = row.acNo.trim();
|
||||
const alreadyLoaded = this.accountDetails && this.lastLoadedAcNo === acNo;
|
||||
if (acNo && !alreadyLoaded) {
|
||||
this.loadAccountDetails(acNo, row.acTyp);
|
||||
}
|
||||
}
|
||||
|
||||
private loadAccountDetails(acNo: string, acTyp: string): void {
|
||||
this.lastLoadedAcNo = acNo;
|
||||
this.loadingAccount = true;
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
@@ -1389,6 +1470,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.signatureUrl = url;
|
||||
// Signature arrives after the main load — refresh shared state so embedded views get it
|
||||
this.persistClerkState();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1405,6 +1488,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// Today's transactions — savings uses "content", CA uses "pageItems"
|
||||
this.todayTransactions =
|
||||
todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []);
|
||||
|
||||
// Push full state so embedded facility/photo/SI/activity views (e.g. manager review) can read it
|
||||
this.persistClerkState();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1594,7 +1680,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get isClerk(): boolean {
|
||||
return this.authService.isClerkUser();
|
||||
// clerkMode forces the clerk layout (used by the clerk dashboard and the manager review screen)
|
||||
return this.clerkMode || this.authService.isClerkUser();
|
||||
}
|
||||
|
||||
get reviewMode(): boolean {
|
||||
return this.reviewRequestId != null;
|
||||
}
|
||||
|
||||
get editMode(): boolean {
|
||||
return this.editRequestId != null;
|
||||
}
|
||||
|
||||
/** Only REJECTED requests are editable; PENDING/APPROVED open read-only. */
|
||||
get editResubmittable(): boolean {
|
||||
return this.editMode && this.editRequest?.status === 'REJECTED';
|
||||
}
|
||||
|
||||
/** Form is locked in manager review, or when viewing a non-rejected request in edit mode. */
|
||||
get formReadOnly(): boolean {
|
||||
return this.reviewMode || (this.editMode && !this.editResubmittable);
|
||||
}
|
||||
|
||||
get isNonIndividual(): boolean {
|
||||
@@ -1901,12 +2006,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const row = this.selectedRow;
|
||||
if (!row || !row.acNo.trim() || !this.accountDetails) return;
|
||||
|
||||
// Cash payment → amount MUST come from denomination (not manual entry)
|
||||
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||
const amount = isCash ? this.denominationTotal : (row.amount ?? this.denominationTotal);
|
||||
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
||||
const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal);
|
||||
|
||||
if (isCash && this.denominationTotal <= 0) {
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode) {
|
||||
this.showAlert('error', 'Denomination Required', [
|
||||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||||
]);
|
||||
@@ -1918,6 +2023,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
|
||||
// Token number is required for all withdrawals (clerk must enter, cashier must enter approved token)
|
||||
if (!this.reviewMode && !isDeposit && !row.tokNo?.trim()) {
|
||||
this.showAlert('error', 'Token Number Required', [
|
||||
this.isClerk
|
||||
? 'Please enter a Token Number (TOK. NO.) for this withdrawal request.'
|
||||
: 'Please enter the approved Token Number (TOK. NO.) to process this withdrawal.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
|
||||
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
|
||||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||
'Withdrawals cannot be processed directly by the cashier.',
|
||||
'All withdrawals must go through the Clerk → Manager approval process.',
|
||||
'Enter the approved Token Number (TOK. NO.) to disburse cash.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved withdrawal: amount/denomination must exactly match
|
||||
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
|
||||
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
|
||||
if (Math.abs(amount - approvedAmt) > 0.005) {
|
||||
if (isCash) {
|
||||
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
|
||||
`Cash denomination total ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })} does not match the approved withdrawal amount ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||||
`Adjust the notes/coins breakdown to total exactly ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`
|
||||
]);
|
||||
} else {
|
||||
this.showAlert('error', 'Amount Mismatch — Approved Withdrawal', [
|
||||
`Amount has been changed from the approved value.`,
|
||||
`Approved: ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Entered: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
'Reset the amount to the approved value before proceeding.'
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const isWithdrawal = !isDeposit;
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────
|
||||
@@ -2129,10 +2275,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||
// ── Confirmation dialog ────────────────────────────────────────────────
|
||||
const balanceAfterTxn = isDeposit ? this.currentBalance + amount : this.currentBalance - amount;
|
||||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||||
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
|
||||
const balanceAfterTxn = isDisbursement
|
||||
? this.currentBalance // no further deduction — just handing cash
|
||||
: isDeposit
|
||||
? this.currentBalance + amount
|
||||
: this.currentBalance - amount;
|
||||
|
||||
this.confirmData = {
|
||||
type: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
||||
type: isDisbursement ? 'DISBURSEMENT' : isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
||||
isDisbursement,
|
||||
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
|
||||
clientName: this.clientName,
|
||||
amount,
|
||||
@@ -2140,7 +2293,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
balanceAfter: balanceAfterTxn,
|
||||
paymentType: selectedPt?.name ?? 'Cash',
|
||||
chequeNo: row.chequeNo || null,
|
||||
symbol: this.currencySymbol
|
||||
symbol: this.currencySymbol,
|
||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? null
|
||||
};
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
@@ -2160,6 +2314,57 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
|
||||
if (this.clerkMode && !isDeposit) {
|
||||
this.submitClerkWithdrawalRequest(row, amount);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
|
||||
// Extra safety net in case confirmSubmit() is ever called directly
|
||||
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
|
||||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||
'Withdrawals must go through the maker-checker approval process.',
|
||||
'Enter the approved token number to disburse.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Token-based approved withdrawal → balance already deducted at manager approval
|
||||
// Do NOT post another savings transaction — just record disbursement
|
||||
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
|
||||
this.saving = true;
|
||||
const denomJson = JSON.stringify(this.denominationCounts);
|
||||
const req = this.activeWithdrawalRequest;
|
||||
this.clerkWithdrawalService
|
||||
.disburse(req.id, denomJson, amount)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.message ??
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
'Disbursement recording failed. Please try again.';
|
||||
this.showAlert('error', 'Disbursement Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
this.showAlert('success', 'Cash Disbursed Successfully', [
|
||||
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
|
||||
`Token No: ${req.tok_no} · Client: ${req.client_name}`
|
||||
]);
|
||||
this.activeWithdrawalRequest = null;
|
||||
this.clearDenominations();
|
||||
this.loadOpeningPosition();
|
||||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||||
|
||||
@@ -2224,6 +2429,235 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Manager review mode ──────────────────────────────────────────────────
|
||||
|
||||
/** Load a withdrawal request and pre-fill the workspace (manager review or clerk edit). */
|
||||
private loadRequestIntoForm(id: number, mode: 'review' | 'edit'): void {
|
||||
this.clerkWithdrawalService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((req: any) => {
|
||||
if (!req) {
|
||||
this.showAlert('error', 'Request Not Found', ['This withdrawal request could not be loaded.']);
|
||||
return;
|
||||
}
|
||||
if (mode === 'review') {
|
||||
this.reviewRequest = req;
|
||||
} else {
|
||||
this.editRequest = req;
|
||||
}
|
||||
|
||||
const acTyp = 'SD';
|
||||
const row = this.rows[0];
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = acTyp;
|
||||
row.paymentTypeId = req.payment_type_id ?? null;
|
||||
row.amount = Number(req.amount) || 0;
|
||||
row.description = req.description ?? '';
|
||||
row.chequeNo = req.cheque_no ?? '';
|
||||
row.bankName = req.bank_name ?? '';
|
||||
row.tokNo = req.tok_no ?? '';
|
||||
row.dbCr = 'Dr';
|
||||
this.selectedRow = row;
|
||||
|
||||
try {
|
||||
this.denominationCounts = req.denomination_data ? JSON.parse(req.denomination_data) : {};
|
||||
} catch {
|
||||
this.denominationCounts = {};
|
||||
}
|
||||
|
||||
if (req.account_no) {
|
||||
this.loadAccountDetails(req.account_no, acTyp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Manager approves → backend posts the real savings withdrawal and deducts the balance. */
|
||||
approveReview(): void {
|
||||
if (!this.reviewRequest || this.saving) return;
|
||||
this.saving = true;
|
||||
this.clerkWithdrawalService
|
||||
.approve(this.reviewRequest.id)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Approval failed. Please try again.';
|
||||
this.showAlert('error', 'Approval Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
this.showAlert(
|
||||
'success',
|
||||
'Withdrawal Approved',
|
||||
[
|
||||
'The withdrawal has been approved and the balance has been deducted.',
|
||||
res.txnId ? `Transaction Ref: ${res.txnId}` : ''
|
||||
].filter(Boolean)
|
||||
);
|
||||
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openRejectReview(): void {
|
||||
this.rejectionReason = '';
|
||||
this.showRejectDialog = true;
|
||||
}
|
||||
|
||||
cancelRejectReview(): void {
|
||||
this.showRejectDialog = false;
|
||||
this.rejectionReason = '';
|
||||
}
|
||||
|
||||
confirmRejectReview(): void {
|
||||
if (!this.reviewRequest || this.saving) return;
|
||||
if (!this.rejectionReason.trim()) {
|
||||
this.showAlert('error', 'Reason Required', ['Please enter a reason for rejecting this request.']);
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.clerkWithdrawalService
|
||||
.reject(this.reviewRequest.id, this.rejectionReason.trim())
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Rejection failed. Please try again.';
|
||||
this.showAlert('error', 'Rejection Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
this.showRejectDialog = false;
|
||||
if (res) {
|
||||
this.showAlert('success', 'Withdrawal Rejected', [
|
||||
'The request has been sent back to the clerk for correction.'
|
||||
]);
|
||||
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
backToInbox(): void {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']);
|
||||
}
|
||||
|
||||
/** Build the withdrawal-request payload from the current row + loaded account. */
|
||||
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
|
||||
const today = new Date();
|
||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
|
||||
return {
|
||||
accountNo: this.accountDetails.accountNo,
|
||||
accountType: 'SAVINGS',
|
||||
savingsAccountId: this.accountDetails.id,
|
||||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
||||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
||||
amount,
|
||||
txnDate: txnDateIso,
|
||||
paymentTypeId: row.paymentTypeId || null,
|
||||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||||
description: row.description || null,
|
||||
chequeNo: row.chequeNo || null,
|
||||
bankName: row.bankName || null,
|
||||
tokNo: row.tokNo?.trim() || null,
|
||||
denominationData: JSON.stringify(this.denominationCounts)
|
||||
};
|
||||
}
|
||||
|
||||
/** Clerk edits a rejected request → resubmit (status back to PENDING) for manager approval. */
|
||||
resubmitEdit(): void {
|
||||
if (!this.editRequest || this.saving) return;
|
||||
const row = this.selectedRow;
|
||||
if (!row || !this.accountDetails) {
|
||||
this.showAlert('error', 'Account Required', ['Please load the account before resubmitting.']);
|
||||
return;
|
||||
}
|
||||
const amount = row.amount ?? 0;
|
||||
if (!amount || amount <= 0) {
|
||||
this.showAlert('error', 'Amount Required', ['Please enter a valid withdrawal amount.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = this.buildWithdrawalPayload(row, amount);
|
||||
this.saving = true;
|
||||
this.clerkWithdrawalService
|
||||
.update(this.editRequest.id, payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Failed to resubmit request. Please try again.';
|
||||
this.showAlert('error', 'Resubmit Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
const tok = (this.editRequest as any)?.tok_no ?? row.tokNo?.trim() ?? '—';
|
||||
this.showAlert('success', 'Withdrawal Resubmitted', [
|
||||
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} resubmitted for manager approval.`,
|
||||
`Token No: ${tok}`
|
||||
]);
|
||||
setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.router.navigate(['/clerk/withdrawal-requests']);
|
||||
}
|
||||
|
||||
/** Clerk maker-checker: create a PENDING withdrawal request instead of deducting balance directly. */
|
||||
private submitClerkWithdrawalRequest(row: TxnRow, amount: number): void {
|
||||
const payload = this.buildWithdrawalPayload(row, amount);
|
||||
|
||||
this.saving = true;
|
||||
this.clerkWithdrawalService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Failed to submit withdrawal request. Please try again.';
|
||||
this.showAlert('error', 'Request Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
const ref = res.resourceId ?? res.requestId ?? res.id ?? 'N/A';
|
||||
const tok = row.tokNo?.trim() ?? '—';
|
||||
this.showAlert('success', 'Withdrawal Request Submitted', [
|
||||
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
|
||||
'Balance will be deducted only after approval.',
|
||||
`Token No: ${tok} · Request No: ${ref}`
|
||||
]);
|
||||
this.onCancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.clerkUiState.clear();
|
||||
this.accountDetails = null;
|
||||
|
||||
Reference in New Issue
Block a user