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),
|
import('../clerk/clerk-dashboard/clerk-dashboard.component').then((m) => m.ClerkDashboardComponent),
|
||||||
data: { title: 'Clerk Dashboard', breadcrumb: 'Clerk Dashboard' }
|
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',
|
path: 'account-facilities',
|
||||||
loadComponent: () =>
|
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">
|
<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 ───────────────────────────── -->
|
<!-- ── Cashier Day-Status Blocking Banners ───────────────────────────── -->
|
||||||
@if (previousDayUnsettled) {
|
@if (previousDayUnsettled) {
|
||||||
<div class="te-day-block te-day-block--danger">
|
<div class="te-day-block te-day-block--danger">
|
||||||
@@ -186,17 +202,27 @@
|
|||||||
<span class="ws-caption">Payment instrument</span>
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Transaction Amount">AMOUNT</label>
|
<label class="ws-f__lbl" title="Transaction Amount">
|
||||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row">
|
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>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="isCashPayment && selectedRow === row ? denominationTotal || '' : (row.amount ?? '')"
|
[value]="row.amount ?? ''"
|
||||||
[disabled]="isCashPayment && selectedRow === row"
|
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +246,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--tok">
|
<div class="ws-f ws-f--tok">
|
||||||
<label class="ws-f__lbl" title="Token Number">TOK. NO.</label>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +283,45 @@
|
|||||||
</div>
|
</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 ────────────────────────────────────────────────────────── -->
|
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
|
||||||
@if (loadingAccount) {
|
@if (loadingAccount) {
|
||||||
<div class="te-loading">
|
<div class="te-loading">
|
||||||
@@ -1057,177 +1130,283 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ws-rows">
|
<div class="ws-rows">
|
||||||
@for (row of rows; track $index; let i = $index) {
|
<fieldset class="ws-rows-fieldset" [disabled]="formReadOnly">
|
||||||
<div class="ws-card" [class.ws-card--active]="selectedRow === row" (click)="onRowSelect(row, i)">
|
@for (row of rows; track $index; let i = $index) {
|
||||||
<div class="ws-card__badge">{{ i + 1 }}</div>
|
<div class="ws-card" [class.ws-card--active]="selectedRow === row" (click)="onRowSelect(row, i)">
|
||||||
<div class="ws-card__body">
|
<div class="ws-card__badge">{{ i + 1 }}</div>
|
||||||
<div class="ws-group-row">
|
<div class="ws-card__body">
|
||||||
<div class="ws-group">
|
<div class="ws-group-row">
|
||||||
<span class="ws-caption">Classification</span>
|
<div class="ws-group">
|
||||||
<div class="ws-group__fields">
|
<span class="ws-caption">Classification</span>
|
||||||
<div class="ws-f ws-f--type">
|
<div class="ws-group__fields">
|
||||||
<label class="ws-f__lbl">TYPE</label>
|
<div class="ws-f ws-f--type">
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
<label class="ws-f__lbl">TYPE</label>
|
||||||
<option value="~">~</option>
|
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
<option value="~">~</option>
|
||||||
<option value="Cheque">Cheque</option>
|
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
||||||
<option value="Disbursement">Disbursement</option>
|
<option value="Cheque">Cheque</option>
|
||||||
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
|
<option value="Disbursement">Disbursement</option>
|
||||||
<option value="DD">DD (Demand Draft)</option>
|
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
|
||||||
</select>
|
<option value="DD">DD (Demand Draft)</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="ws-f ws-f--branch">
|
</div>
|
||||||
<label class="ws-f__lbl">BRANCH</label>
|
<div class="ws-f ws-f--branch">
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
<label class="ws-f__lbl">BRANCH</label>
|
||||||
<option [ngValue]="1">001 · Main</option>
|
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
||||||
<option [ngValue]="2">002 · M.G. Road</option>
|
<option [ngValue]="1">001 · Main</option>
|
||||||
<option [ngValue]="14">014 · Station Rd</option>
|
<option [ngValue]="2">002 · M.G. Road</option>
|
||||||
<option [ngValue]="27">027 · Industrial</option>
|
<option [ngValue]="14">014 · Station Rd</option>
|
||||||
</select>
|
<option [ngValue]="27">027 · Industrial</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="ws-f ws-f--paytype">
|
</div>
|
||||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
<div class="ws-f ws-f--paytype">
|
||||||
<select
|
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||||
class="ws-f__ctl ws-f__ctl--accent"
|
<select
|
||||||
[(ngModel)]="row.paymentTypeId"
|
class="ws-f__ctl ws-f__ctl--accent"
|
||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[(ngModel)]="row.paymentTypeId"
|
||||||
>
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
<option [ngValue]="null">— Select —</option>
|
>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
<option [ngValue]="null">— Select —</option>
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
@for (pt of paymentTypes; track pt.id) {
|
||||||
}
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
</select>
|
}
|
||||||
</div>
|
</select>
|
||||||
<div class="ws-f ws-f--actyp">
|
</div>
|
||||||
<label class="ws-f__lbl">AC. TP.</label>
|
<div class="ws-f ws-f--actyp">
|
||||||
<select
|
<label class="ws-f__lbl">AC. TP.</label>
|
||||||
class="ws-f__ctl"
|
<select
|
||||||
[(ngModel)]="row.acTyp"
|
class="ws-f__ctl"
|
||||||
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
|
[(ngModel)]="row.acTyp"
|
||||||
>
|
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
|
||||||
@for (a of acTypOptions; track a) {
|
>
|
||||||
<option [value]="a">{{ a }}</option>
|
@for (a of acTypOptions; track a) {
|
||||||
}
|
<option [value]="a">{{ a }}</option>
|
||||||
</select>
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="ws-vdivider"></div>
|
||||||
<div class="ws-vdivider"></div>
|
<div class="ws-group ws-group--flex">
|
||||||
<div class="ws-group ws-group--flex">
|
<span class="ws-caption">Account</span>
|
||||||
<span class="ws-caption">Account</span>
|
<div class="ws-group__fields">
|
||||||
<div class="ws-group__fields">
|
<div class="ws-f ws-f--acno">
|
||||||
<div class="ws-f ws-f--acno">
|
<label class="ws-f__lbl">A/C NO.</label>
|
||||||
<label class="ws-f__lbl">A/C NO.</label>
|
<input
|
||||||
<input
|
class="ws-f__ctl ws-f__ctl--mono"
|
||||||
class="ws-f__ctl ws-f__ctl--mono"
|
[value]="row.acNo"
|
||||||
[value]="row.acNo"
|
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
(blur)="onAcNoBlur(row, i)"
|
||||||
(blur)="onAcNoBlur(row, i)"
|
placeholder="Account number"
|
||||||
placeholder="Account number"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div class="ws-f ws-f--ecsno">
|
||||||
<div class="ws-f ws-f--ecsno">
|
<label class="ws-f__lbl">ECS A/C NO.</label>
|
||||||
<label class="ws-f__lbl">ECS A/C NO.</label>
|
<input
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.ecsAcNo" placeholder="ECS account" />
|
class="ws-f__ctl ws-f__ctl--mono"
|
||||||
</div>
|
[(ngModel)]="row.ecsAcNo"
|
||||||
<div class="ws-f ws-f--dbcr">
|
placeholder="ECS account"
|
||||||
<label class="ws-f__lbl">DR./CR.</label>
|
/>
|
||||||
<div class="ws-seg">
|
</div>
|
||||||
<button
|
<div class="ws-f ws-f--dbcr">
|
||||||
type="button"
|
<label class="ws-f__lbl">DR./CR.</label>
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
<div class="ws-seg">
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
<button
|
||||||
[disabled]="row.acTyp === 'GL'"
|
type="button"
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
>
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
DR
|
[disabled]="row.acTyp === 'GL'"
|
||||||
</button>
|
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||||
<button
|
>
|
||||||
type="button"
|
DR
|
||||||
class="ws-seg__btn ws-seg__btn--cr"
|
</button>
|
||||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
<button
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
type="button"
|
||||||
>
|
class="ws-seg__btn ws-seg__btn--cr"
|
||||||
CR
|
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||||
</button>
|
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
||||||
|
>
|
||||||
|
CR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="ws-group-row ws-group-row--mt">
|
||||||
<div class="ws-group-row ws-group-row--mt">
|
<div class="ws-group">
|
||||||
<div class="ws-group">
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<span class="ws-caption">Payment instrument</span>
|
<div class="ws-group__fields">
|
||||||
<div class="ws-group__fields">
|
<div class="ws-f ws-f--amt">
|
||||||
<div class="ws-f ws-f--amt">
|
<label class="ws-f__lbl">
|
||||||
<label class="ws-f__lbl">AMOUNT</label>
|
AMOUNT
|
||||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row">
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<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
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl"
|
||||||
type="number"
|
[(ngModel)]="row.tokNo"
|
||||||
[value]="
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
isCashPayment && selectedRow === row ? denominationTotal || '' : (row.amount ?? '')
|
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
"
|
placeholder="Enter token"
|
||||||
[disabled]="isCashPayment && selectedRow === row"
|
(blur)="onTokNoBlur(row)"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="ws-vdivider"></div>
|
||||||
<div class="ws-vdivider"></div>
|
<div class="ws-group ws-group--flex">
|
||||||
<div class="ws-group ws-group--flex">
|
<span class="ws-caption">Narration</span>
|
||||||
<span class="ws-caption">Narration</span>
|
<div class="ws-group__fields">
|
||||||
<div class="ws-group__fields">
|
<div class="ws-f ws-f--desc">
|
||||||
<div class="ws-f ws-f--desc">
|
<label class="ws-f__lbl">DESCRIPTION</label>
|
||||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
||||||
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons footer -->
|
<!-- Action buttons footer -->
|
||||||
<div class="ws-footer">
|
@if (reviewMode) {
|
||||||
<div class="ws-footer__stats">
|
<div class="ws-footer">
|
||||||
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="onExit()">Exit</button>
|
<div class="ws-footer__stats">
|
||||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onCancel()">Cancel</button>
|
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="backToInbox()">
|
||||||
<button class="ws-btn ws-btn--ghost" type="button">Drop</button>
|
← Back to Inbox
|
||||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
</button>
|
||||||
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</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>
|
||||||
<div class="ws-footer__actions">
|
} @else if (editMode) {
|
||||||
<button class="ws-btn ws-btn--post" type="button" (click)="onSave()" [disabled]="saving">
|
<div class="ws-footer">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<div class="ws-footer__stats">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<button class="ws-btn ws-btn--ghost ws-btn--exit" type="button" (click)="cancelEdit()">
|
||||||
</svg>
|
← My Requests
|
||||||
{{ saving ? 'Posting…' : 'Save' }}
|
</button>
|
||||||
</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>
|
||||||
</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>
|
</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 ──────────────────────────────────────────────── -->
|
<!-- ── CONFIRMATION DIALOG ──────────────────────────────────────────────── -->
|
||||||
@if (showConfirmDialog && confirmData) {
|
@if (showConfirmDialog && confirmData) {
|
||||||
<div class="te-confirm-overlay" (click)="showConfirmDialog = false">
|
<div class="te-confirm-overlay" (click)="showConfirmDialog = false">
|
||||||
@@ -1236,9 +1415,10 @@
|
|||||||
class="te-confirm-header"
|
class="te-confirm-header"
|
||||||
[class.te-confirm-header--deposit]="confirmData.type === 'DEPOSIT'"
|
[class.te-confirm-header--deposit]="confirmData.type === 'DEPOSIT'"
|
||||||
[class.te-confirm-header--withdrawal]="confirmData.type === 'WITHDRAWAL'"
|
[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 class="te-confirm-icon">{{ confirmData.type === 'DEPOSIT' ? '⬇' : '⬆' }}</span>
|
||||||
<span>Confirm {{ confirmData.type }}</span>
|
<span>Confirm {{ confirmData.isDisbursement ? 'CASH DISBURSEMENT' : confirmData.type }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-confirm-body">
|
<div class="te-confirm-body">
|
||||||
<div class="te-confirm-row">
|
<div class="te-confirm-row">
|
||||||
@@ -1258,39 +1438,69 @@
|
|||||||
><span class="te-confirm-val te-mono">{{ confirmData.chequeNo }}</span>
|
><span class="te-confirm-val te-mono">{{ confirmData.chequeNo }}</span>
|
||||||
</div>
|
</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-divider"></div>
|
||||||
<div class="te-confirm-row">
|
|
||||||
<span class="te-confirm-label">Current Balance</span
|
@if (confirmData.isDisbursement) {
|
||||||
><span class="te-confirm-val"
|
<!-- Token-based disbursement: balance was already deducted at manager approval -->
|
||||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
<div class="te-confirm-row">
|
||||||
>
|
<span class="te-confirm-label">Current Balance</span>
|
||||||
</div>
|
<span class="te-confirm-val"
|
||||||
<div class="te-confirm-row te-confirm-row--amount">
|
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||||
<span class="te-confirm-label">{{ confirmData.type === 'DEPOSIT' ? 'Deposit' : 'Withdrawal' }} Amount</span>
|
>
|
||||||
<span
|
</div>
|
||||||
class="te-confirm-val"
|
<div class="te-confirm-row te-confirm-row--amount">
|
||||||
[class.te-cr]="confirmData.type === 'DEPOSIT'"
|
<span class="te-confirm-label">Disburse Amount</span>
|
||||||
[class.te-dr]="confirmData.type === 'WITHDRAWAL'"
|
<span class="te-confirm-val te-cr">{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span>
|
||||||
>
|
</div>
|
||||||
{{ confirmData.type === 'DEPOSIT' ? '+' : '−' }}{{ confirmData.symbol
|
<div class="te-confirm-row te-confirm-disbursed-note">
|
||||||
}}{{ confirmData.amount | number: '1.2-2' }}
|
<span class="te-confirm-disbursed-badge"
|
||||||
</span>
|
>Balance already deducted at manager approval — no further deduction</span
|
||||||
</div>
|
>
|
||||||
<div class="te-confirm-row te-confirm-row--after">
|
</div>
|
||||||
<span class="te-confirm-label">Balance After</span>
|
} @else {
|
||||||
<span
|
<div class="te-confirm-row">
|
||||||
class="te-confirm-val"
|
<span class="te-confirm-label">Current Balance</span
|
||||||
[class.te-cr]="confirmData.balanceAfter >= 0"
|
><span class="te-confirm-val"
|
||||||
[class.te-dr]="confirmData.balanceAfter < 0"
|
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
<strong>{{ confirmData.symbol }}{{ confirmData.balanceAfter | number: '1.2-2' }}</strong>
|
</div>
|
||||||
</span>
|
<div class="te-confirm-row te-confirm-row--amount">
|
||||||
</div>
|
<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>
|
||||||
<div class="te-confirm-actions">
|
<div class="te-confirm-actions">
|
||||||
<button class="te-btn" type="button" (click)="showConfirmDialog = false">Cancel</button>
|
<button class="te-btn" type="button" (click)="showConfirmDialog = false">Cancel</button>
|
||||||
<button class="te-btn te-btn--primary" type="button" [disabled]="saving" (click)="confirmSubmit()">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -473,6 +473,161 @@ $ws-fld-bd: #c8d8eb;
|
|||||||
cursor: not-allowed;
|
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 ────────────────────────────────────────────────────────────────
|
// ── Row cards ────────────────────────────────────────────────────────────────
|
||||||
@@ -736,6 +891,35 @@ select.ws-f__ctl {
|
|||||||
cursor: pointer;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Amount field ──────────────────────────────────────────────────────────────
|
||||||
.ws-amt-wrap {
|
.ws-amt-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -765,7 +949,6 @@ select.ws-f__ctl {
|
|||||||
.ws-amt-input {
|
.ws-amt-input {
|
||||||
color: #15803d;
|
color: #15803d;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1591,13 +1774,12 @@ select.te-cell {
|
|||||||
background: #fff7ed !important;
|
background: #fff7ed !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cash payment: amount auto-filled from denomination (disabled style)
|
// Cash payment: amount highlighted style
|
||||||
.te-cell--cash-auto {
|
.te-cell--cash-auto {
|
||||||
background: #f0fdf4 !important;
|
background: #f0fdf4 !important;
|
||||||
border-color: #86efac !important;
|
border-color: #86efac !important;
|
||||||
color: #15803d !important;
|
color: #15803d !important;
|
||||||
font-weight: 700 !important;
|
font-weight: 700 !important;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Denomination stock overdraft badge (closing position header)
|
// Denomination stock overdraft badge (closing position header)
|
||||||
@@ -2089,6 +2271,10 @@ select.te-cell {
|
|||||||
&--withdrawal {
|
&--withdrawal {
|
||||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--disbursement {
|
||||||
|
background: linear-gradient(135deg, #0284c7, #0369a1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.te-confirm-icon {
|
.te-confirm-icon {
|
||||||
@@ -2140,6 +2326,25 @@ select.te-cell {
|
|||||||
margin: 6px 0;
|
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 {
|
.te-confirm-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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/.
|
* 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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
import { forkJoin, of, Subject } from 'rxjs';
|
import { forkJoin, of, Subject } from 'rxjs';
|
||||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
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 { CashierService } from '../cashier.service';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||||
|
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||||
|
import { SettingsService } from '../../settings/settings.service';
|
||||||
|
|
||||||
interface TxnRow {
|
interface TxnRow {
|
||||||
typ: string;
|
typ: string;
|
||||||
@@ -620,15 +624,37 @@ export class ImagePreviewDialogComponent {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatDialogModule
|
MatDialogModule,
|
||||||
|
MatIconModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TransactionEntryComponent implements OnInit, OnDestroy {
|
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 http = inject(HttpClient);
|
||||||
private cashierService = inject(CashierService);
|
private cashierService = inject(CashierService);
|
||||||
private authService = inject(AuthenticationService);
|
private authService = inject(AuthenticationService);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
|
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
today = '';
|
today = '';
|
||||||
rows: TxnRow[] = [];
|
rows: TxnRow[] = [];
|
||||||
@@ -659,6 +685,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Search token — cancels stale async results
|
// Search token — cancels stale async results
|
||||||
private searchToken = 0;
|
private searchToken = 0;
|
||||||
|
private lastLoadedAcNo = '';
|
||||||
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
||||||
cashVaultGlId: number | null = null;
|
cashVaultGlId: number | null = null;
|
||||||
|
|
||||||
@@ -770,6 +797,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.loadCashVaultGl();
|
this.loadCashVaultGl();
|
||||||
|
|
||||||
|
if (this.reviewMode) {
|
||||||
|
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.editMode) {
|
||||||
|
this.loadRequestIntoForm(this.editRequestId!, 'edit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isClerk) {
|
if (this.isClerk) {
|
||||||
const saved = this.clerkUiState.snapshot.savedAccount;
|
const saved = this.clerkUiState.snapshot.savedAccount;
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -799,6 +836,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
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) {
|
if (this.isClerk && this.accountDetails) {
|
||||||
this.clerkUiState.saveFullState({
|
this.clerkUiState.saveFullState({
|
||||||
accountDetails: this.accountDetails,
|
accountDetails: this.accountDetails,
|
||||||
@@ -1014,7 +1056,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addRow(): void {
|
addRow(): void {
|
||||||
this.rows.push({
|
const row: TxnRow = {
|
||||||
typ: '~',
|
typ: '~',
|
||||||
br: 1,
|
br: 1,
|
||||||
trTp: '',
|
trTp: '',
|
||||||
@@ -1030,7 +1072,45 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
paymentTypeId: null,
|
paymentTypeId: null,
|
||||||
bankName: '',
|
bankName: '',
|
||||||
routingCode: ''
|
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 {
|
onAcNoBlur(row: TxnRow, index: number): void {
|
||||||
@@ -1041,6 +1121,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
onAcNoInput(row: TxnRow, index: number, value: string): void {
|
onAcNoInput(row: TxnRow, index: number, value: string): void {
|
||||||
row.acNo = value;
|
row.acNo = value;
|
||||||
|
this.lastLoadedAcNo = '';
|
||||||
this.acNoInput$.next({ row, index, value });
|
this.acNoInput$.next({ row, index, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,15 +1143,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
onRowSelect(row: TxnRow, index: number): void {
|
onRowSelect(row: TxnRow, index: number): void {
|
||||||
this.selectedRow = row;
|
this.selectedRow = row;
|
||||||
// Only reload if account number changed or nothing is loaded yet
|
const acNo = row.acNo.trim();
|
||||||
const loadedId = this.accountDetails?.glCode ?? this.accountDetails?.accountNo;
|
const alreadyLoaded = this.accountDetails && this.lastLoadedAcNo === acNo;
|
||||||
const alreadyLoaded = this.accountDetails && loadedId === row.acNo.trim();
|
if (acNo && !alreadyLoaded) {
|
||||||
if (row.acNo.trim() && !alreadyLoaded) {
|
this.loadAccountDetails(acNo, row.acTyp);
|
||||||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadAccountDetails(acNo: string, acTyp: string): void {
|
private loadAccountDetails(acNo: string, acTyp: string): void {
|
||||||
|
this.lastLoadedAcNo = acNo;
|
||||||
this.loadingAccount = true;
|
this.loadingAccount = true;
|
||||||
this.accountDetails = null;
|
this.accountDetails = null;
|
||||||
this.clientDetails = null;
|
this.clientDetails = null;
|
||||||
@@ -1389,6 +1470,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
if (blob) {
|
if (blob) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
this.signatureUrl = url;
|
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"
|
// Today's transactions — savings uses "content", CA uses "pageItems"
|
||||||
this.todayTransactions =
|
this.todayTransactions =
|
||||||
todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []);
|
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 {
|
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 {
|
get isNonIndividual(): boolean {
|
||||||
@@ -1901,12 +2006,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const row = this.selectedRow;
|
const row = this.selectedRow;
|
||||||
if (!row || !row.acNo.trim() || !this.accountDetails) return;
|
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 selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
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', [
|
this.showAlert('error', 'Denomination Required', [
|
||||||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||||||
]);
|
]);
|
||||||
@@ -1918,6 +2023,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDeposit = row.dbCr === 'Cr';
|
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;
|
const isWithdrawal = !isDeposit;
|
||||||
|
|
||||||
// ── Validation ─────────────────────────────────────────────────────────
|
// ── Validation ─────────────────────────────────────────────────────────
|
||||||
@@ -2129,10 +2275,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||||
// ── Confirmation dialog ────────────────────────────────────────────────
|
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||||||
const balanceAfterTxn = isDeposit ? this.currentBalance + amount : this.currentBalance - amount;
|
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 = {
|
this.confirmData = {
|
||||||
type: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
type: isDisbursement ? 'DISBURSEMENT' : isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
||||||
|
isDisbursement,
|
||||||
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
|
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
|
||||||
clientName: this.clientName,
|
clientName: this.clientName,
|
||||||
amount,
|
amount,
|
||||||
@@ -2140,7 +2293,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
balanceAfter: balanceAfterTxn,
|
balanceAfter: balanceAfterTxn,
|
||||||
paymentType: selectedPt?.name ?? 'Cash',
|
paymentType: selectedPt?.name ?? 'Cash',
|
||||||
chequeNo: row.chequeNo || null,
|
chequeNo: row.chequeNo || null,
|
||||||
symbol: this.currencySymbol
|
symbol: this.currencySymbol,
|
||||||
|
tokNo: this.activeWithdrawalRequest?.tok_no ?? null
|
||||||
};
|
};
|
||||||
this.showConfirmDialog = true;
|
this.showConfirmDialog = true;
|
||||||
}
|
}
|
||||||
@@ -2160,6 +2314,57 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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 today = new Date();
|
||||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
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 {
|
onCancel(): void {
|
||||||
this.clerkUiState.clear();
|
this.clerkUiState.clear();
|
||||||
this.accountDetails = null;
|
this.accountDetails = null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||||
@@ -44,12 +44,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="af-back-btn" (click)="goBack()">
|
@if (!embedded) {
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<button class="af-back-btn" (click)="goBack()">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
</svg>
|
<polyline points="15 18 9 12 15 6" />
|
||||||
Back to Dashboard
|
</svg>
|
||||||
</button>
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!accountLoaded) {
|
@if (!accountLoaded) {
|
||||||
@@ -63,7 +65,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
<p class="af-empty__title">No Account Loaded</p>
|
<p class="af-empty__title">No Account Loaded</p>
|
||||||
<p class="af-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
<p class="af-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
||||||
<button class="af-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
@if (!embedded) {
|
||||||
|
<button class="af-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
@@ -121,6 +125,8 @@ export class AccountFacilitiesComponent implements OnInit {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
@Input() embedded = false;
|
||||||
|
|
||||||
accountLoaded = false;
|
accountLoaded = false;
|
||||||
facilities: { label: string; active: boolean }[] = [];
|
facilities: { label: string; active: boolean }[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ import { TransactionEntryComponent } from '../../cashier/transaction-entry/trans
|
|||||||
selector: 'mifosx-clerk-dashboard',
|
selector: 'mifosx-clerk-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TransactionEntryComponent],
|
imports: [TransactionEntryComponent],
|
||||||
template: `<mifosx-transaction-entry></mifosx-transaction-entry>`
|
template: `<mifosx-transaction-entry [clerkMode]="true"></mifosx-transaction-entry>`
|
||||||
})
|
})
|
||||||
export class ClerkDashboardComponent {}
|
export class ClerkDashboardComponent {}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · WITHDRAWAL REQUESTS</div>
|
||||||
|
<h1 class="pg-title">My Withdrawal Requests</h1>
|
||||||
|
<p class="pg-sub">Track the status of your submitted withdrawal requests. Edit and resubmit rejected ones.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">
|
||||||
|
<mat-icon>add</mat-icon> New Request
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── FILTER ROW ── -->
|
||||||
|
<div class="pg-filter-row">
|
||||||
|
<select class="myr-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span class="pg-fb-sep"></span>
|
||||||
|
|
||||||
|
<div class="pg-presets">
|
||||||
|
@for (p of datePresets; track p.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-preset-chip"
|
||||||
|
[class.pg-preset-chip--active]="activePreset === p.key"
|
||||||
|
(click)="applyPreset(p.key)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="pg-fb-sep"></span>
|
||||||
|
|
||||||
|
<div class="pg-date-range">
|
||||||
|
<div class="pg-date-box">
|
||||||
|
<span class="pg-date-box__lbl">From</span>
|
||||||
|
<input
|
||||||
|
class="pg-date-box__input"
|
||||||
|
[matDatepicker]="fromPicker"
|
||||||
|
[(ngModel)]="fromDate"
|
||||||
|
[max]="maxDate"
|
||||||
|
(dateChange)="onFromDateChange()"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button type="button" class="pg-date-box__btn" (click)="fromPicker.open()">
|
||||||
|
<mat-icon>event</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-datepicker #fromPicker></mat-datepicker>
|
||||||
|
</div>
|
||||||
|
<span class="pg-date-range__sep">—</span>
|
||||||
|
<div class="pg-date-box">
|
||||||
|
<span class="pg-date-box__lbl">To</span>
|
||||||
|
<input
|
||||||
|
class="pg-date-box__input"
|
||||||
|
[matDatepicker]="toPicker"
|
||||||
|
[(ngModel)]="toDate"
|
||||||
|
[min]="fromDate"
|
||||||
|
[max]="maxDate"
|
||||||
|
(dateChange)="onToDateChange()"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button type="button" class="pg-date-box__btn" (click)="toPicker.open()">
|
||||||
|
<mat-icon>event</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-datepicker #toPicker></mat-datepicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="myr-btn-refresh" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TABLE CARD ── -->
|
||||||
|
<div class="pg-card">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading requests…</div>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon style="font-size: 36px; color: #cbd5e1; display: block; text-align: center">inbox</mat-icon>
|
||||||
|
<p style="margin-top: 8px; color: #94a3b8">No withdrawal requests found.</p>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()" style="margin-top: 12px">
|
||||||
|
<mat-icon>add</mat-icon> Create First Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<table class="pg-table myr-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Payment Mode</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Submitted</th>
|
||||||
|
<th>Reviewed By</th>
|
||||||
|
<th>Rejection / Cashier</th>
|
||||||
|
<th class="myr-th-action">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr
|
||||||
|
class="myr-row"
|
||||||
|
[class.myr-row--pending]="r.status === 'PENDING'"
|
||||||
|
[class.myr-row--approved]="r.status === 'APPROVED'"
|
||||||
|
[class.myr-row--rejected]="r.status === 'REJECTED'"
|
||||||
|
[class.myr-row--disbursed]="r.status === 'DISBURSED'"
|
||||||
|
>
|
||||||
|
<td class="cell-mono">{{ r.id }}</td>
|
||||||
|
<td class="cell-mono">{{ r.account_no }}</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.payment_type_name || 'Cash' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="myr-status" [ngClass]="statusClass(r.status)">
|
||||||
|
{{ statusLabel(r.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||||
|
<td class="myr-rejection">
|
||||||
|
@if (r.status === 'REJECTED' && r.rejection_reason) {
|
||||||
|
<span class="myr-rejection-text" [title]="r.rejection_reason">
|
||||||
|
{{ r.rejection_reason | slice: 0 : 60 }}{{ r.rejection_reason?.length > 60 ? '…' : '' }}
|
||||||
|
</span>
|
||||||
|
} @else if (r.status === 'DISBURSED') {
|
||||||
|
<div class="myr-disbursed-info">
|
||||||
|
<span class="myr-disbursed-cashier">{{ r.disbursed_by || '—' }}</span>
|
||||||
|
<span class="myr-dt">{{ formatDateTime(r.disbursed_at) }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="myr-none">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="myr-actions-cell">
|
||||||
|
@if (r.status === 'REJECTED') {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary myr-btn-edit" (click)="editRequest(r)">
|
||||||
|
<mat-icon>edit</mat-icon> Edit & Resubmit
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="pg-btn myr-btn-view" (click)="viewRequest(r)">
|
||||||
|
<mat-icon>visibility</mat-icon> View
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
[length]="totalRecords"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onPageChange($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-table {
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-row {
|
||||||
|
cursor: default;
|
||||||
|
transition: background 0.1s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected td {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approved td {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approved {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disbursed {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-row--disbursed td {
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-disbursed-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-disbursed-cashier {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-dt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-rejection {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-rejection-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #991b1b;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-none {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-btn-edit {
|
||||||
|
font-size: 12px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-btn-view {
|
||||||
|
font-size: 12px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-th-action {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-btn-refresh {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #64748b;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-fb-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myr-select {
|
||||||
|
height: 32px;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1353a0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-presets {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-preset-chip {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1353a0;
|
||||||
|
color: #1353a0;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #1353a0;
|
||||||
|
border-color: #1353a0;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0f4080;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-date-range {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 300;
|
||||||
|
padding: 0 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-date-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 6px 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #1353a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 72px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1353a0;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatNativeDateModule } from '@angular/material/core';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkWithdrawalService } from '../../cashier/clerk-withdrawal.service';
|
||||||
|
import { SettingsService } from '../../settings/settings.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-requests',
|
||||||
|
templateUrl: './my-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'./my-requests.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatNativeDateModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatDatepickerModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSizeOptions = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50
|
||||||
|
];
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
fromDate: Date | null = null;
|
||||||
|
toDate: Date | null = null;
|
||||||
|
maxDate!: Date;
|
||||||
|
activePreset: string = 'today';
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
readonly datePresets = [
|
||||||
|
{ key: 'today', label: 'Today' },
|
||||||
|
{ key: 'week', label: 'This Week' },
|
||||||
|
{ key: 'month', label: 'This Month' },
|
||||||
|
{ key: 'all', label: 'All' }
|
||||||
|
];
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' },
|
||||||
|
{ value: 'DISBURSED', label: 'Disbursed' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const today = new Date(this.settingsService.businessDate);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
this.maxDate = today;
|
||||||
|
this.applyPreset('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(
|
||||||
|
this.filterStatus || undefined,
|
||||||
|
this.pageIndex * this.pageSize,
|
||||||
|
this.pageSize,
|
||||||
|
this.isoDate(this.fromDate),
|
||||||
|
this.isoDate(this.toDate)
|
||||||
|
)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: PageEvent): void {
|
||||||
|
this.pageSize = event.pageSize;
|
||||||
|
this.pageIndex = event.pageIndex;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFromDateChange(): void {
|
||||||
|
this.activePreset = '';
|
||||||
|
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||||
|
this.toDate = new Date(this.fromDate);
|
||||||
|
}
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToDateChange(): void {
|
||||||
|
this.activePreset = '';
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(key: string): void {
|
||||||
|
this.activePreset = key;
|
||||||
|
const today = new Date(this.settingsService.businessDate);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
if (key === 'today') {
|
||||||
|
this.fromDate = new Date(today);
|
||||||
|
this.toDate = new Date(today);
|
||||||
|
} else if (key === 'week') {
|
||||||
|
const start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - today.getDay());
|
||||||
|
this.fromDate = start;
|
||||||
|
this.toDate = new Date(today);
|
||||||
|
} else if (key === 'month') {
|
||||||
|
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
this.toDate = new Date(today);
|
||||||
|
} else {
|
||||||
|
this.fromDate = null;
|
||||||
|
this.toDate = null;
|
||||||
|
}
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isoDate(d: Date | null): string | undefined {
|
||||||
|
if (!d) return undefined;
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
|
||||||
|
editRequest(req: any): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/clerk/withdrawal-request',
|
||||||
|
req.id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewRequest(req: any): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/clerk/withdrawal-request',
|
||||||
|
req.id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'myr-status--pending',
|
||||||
|
APPROVED: 'myr-status--approved',
|
||||||
|
REJECTED: 'myr-status--rejected',
|
||||||
|
DISBURSED: 'myr-status--disbursed'
|
||||||
|
};
|
||||||
|
return map[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
DISBURSED: 'Disbursed'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||||
@@ -38,12 +38,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="ps-back-btn" (click)="goBack()">
|
@if (!embedded) {
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<button class="ps-back-btn" (click)="goBack()">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
</svg>
|
<polyline points="15 18 9 12 15 6" />
|
||||||
Back to Dashboard
|
</svg>
|
||||||
</button>
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!accountLoaded) {
|
@if (!accountLoaded) {
|
||||||
@@ -57,7 +59,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
<p class="ps-empty__title">No Account Loaded</p>
|
<p class="ps-empty__title">No Account Loaded</p>
|
||||||
<p class="ps-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
<p class="ps-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
||||||
<button class="ps-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
@if (!embedded) {
|
||||||
|
<button class="ps-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="ps-cards-row">
|
<div class="ps-cards-row">
|
||||||
@@ -151,6 +155,8 @@ export class PhotoSignatureComponent implements OnInit {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
@Input() embedded = false;
|
||||||
|
|
||||||
accountLoaded = false;
|
accountLoaded = false;
|
||||||
photoUrl: string | null = null;
|
photoUrl: string | null = null;
|
||||||
signatureUrl: string | null = null;
|
signatureUrl: string | null = null;
|
||||||
@@ -161,15 +167,17 @@ export class PhotoSignatureComponent implements OnInit {
|
|||||||
previewLabel = '';
|
previewLabel = '';
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const s = this.clerkUiState.snapshot;
|
// Subscribe (not snapshot) so a late-arriving signature blob updates the view while embedded
|
||||||
this.accountLoaded = s.accountLoaded;
|
this.clerkUiState.state$.subscribe((s) => {
|
||||||
if (s.savedAccount) {
|
this.accountLoaded = s.accountLoaded;
|
||||||
this.photoUrl = s.savedAccount.clientPhotoUrl;
|
if (s.savedAccount) {
|
||||||
this.signatureUrl = s.savedAccount.signatureUrl;
|
this.photoUrl = s.savedAccount.clientPhotoUrl;
|
||||||
this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
|
this.signatureUrl = s.savedAccount.signatureUrl;
|
||||||
const cd = s.savedAccount.clientDetails;
|
this.accountNo = s.savedAccount.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
|
||||||
this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : '';
|
const cd = s.savedAccount.clientDetails;
|
||||||
}
|
this.clientName = cd ? `${cd.firstname ?? ''} ${cd.lastname ?? ''}`.trim() : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
preview(src: string, label: string): void {
|
preview(src: string, label: string): void {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||||
@@ -44,12 +44,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="si-back-btn" (click)="goBack()">
|
@if (!embedded) {
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<button class="si-back-btn" (click)="goBack()">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
</svg>
|
<polyline points="15 18 9 12 15 6" />
|
||||||
Back to Dashboard
|
</svg>
|
||||||
</button>
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!accountLoaded) {
|
@if (!accountLoaded) {
|
||||||
@@ -64,7 +66,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
<p class="si-empty__title">No Account Loaded</p>
|
<p class="si-empty__title">No Account Loaded</p>
|
||||||
<p class="si-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
<p class="si-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
||||||
<button class="si-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
@if (!embedded) {
|
||||||
|
<button class="si-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (instructions.length === 0) {
|
} @else if (instructions.length === 0) {
|
||||||
<div class="si-empty">
|
<div class="si-empty">
|
||||||
@@ -170,6 +174,8 @@ export class StandingInstructionsComponent implements OnInit {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
@Input() embedded = false;
|
||||||
|
|
||||||
accountLoaded = false;
|
accountLoaded = false;
|
||||||
instructions: any[] = [];
|
instructions: any[] = [];
|
||||||
accountNo = '';
|
accountNo = '';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||||
@@ -46,12 +46,14 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="ta-back-btn" (click)="goBack()">
|
@if (!embedded) {
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<button class="ta-back-btn" (click)="goBack()">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
</svg>
|
<polyline points="15 18 9 12 15 6" />
|
||||||
Back to Dashboard
|
</svg>
|
||||||
</button>
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!accountLoaded) {
|
@if (!accountLoaded) {
|
||||||
@@ -66,7 +68,9 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
</div>
|
</div>
|
||||||
<p class="ta-empty__title">No Account Loaded</p>
|
<p class="ta-empty__title">No Account Loaded</p>
|
||||||
<p class="ta-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
<p class="ta-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
||||||
<button class="ta-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
@if (!embedded) {
|
||||||
|
<button class="ta-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- AML Compliance cards -->
|
<!-- AML Compliance cards -->
|
||||||
@@ -180,6 +184,8 @@ export class TodayActivityComponent implements OnInit {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
@Input() embedded = false;
|
||||||
|
|
||||||
accountLoaded = false;
|
accountLoaded = false;
|
||||||
transactions: any[] = [];
|
transactions: any[] = [];
|
||||||
neftRtgs = 0;
|
neftRtgs = 0;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { TransactionEntryComponent } from '../../cashier/transaction-entry/transaction-entry.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clerk edit wrapper — renders the full clerk dashboard workspace pre-filled from a rejected
|
||||||
|
* withdrawal request, editable, with a Resubmit action.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-withdrawal-edit',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TransactionEntryComponent],
|
||||||
|
template: `<mifosx-transaction-entry [clerkMode]="true" [editRequestId]="requestId"></mifosx-transaction-entry>`
|
||||||
|
})
|
||||||
|
export class WithdrawalEditComponent {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const id = this.route.snapshot.params['id'];
|
||||||
|
this.requestId = id != null ? Number(id) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · WITHDRAWAL' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── SUCCESS STATE ── -->
|
||||||
|
@if (pageState === 'success') {
|
||||||
|
<div class="wdr-success">
|
||||||
|
<mat-icon class="wdr-success__icon">check_circle</mat-icon>
|
||||||
|
<div class="wdr-success__text">
|
||||||
|
@if (isReview) {
|
||||||
|
<p>Action completed successfully.</p>
|
||||||
|
} @else {
|
||||||
|
<p>Withdrawal request submitted for Bank Manager approval.</p>
|
||||||
|
}
|
||||||
|
<p class="wdr-success__sub">Redirecting…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (pageState !== 'success') {
|
||||||
|
<!-- ── REJECTION BANNER (clerk edit mode) ── -->
|
||||||
|
@if (isEdit && existingRequest?.status === 'REJECTED') {
|
||||||
|
<div class="wdr-rejection-banner">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>This request was rejected</strong>
|
||||||
|
@if (existingRequest?.rejection_reason) {
|
||||||
|
<p>Reason: {{ existingRequest.rejection_reason }}</p>
|
||||||
|
}
|
||||||
|
<p>Please update the details below and resubmit for approval.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── ACCOUNT SEARCH (only in create mode) ── -->
|
||||||
|
@if (isCreate) {
|
||||||
|
<div class="sc-search-bar">
|
||||||
|
<div class="sc-search-bar__input-wrap">
|
||||||
|
<mat-icon class="sc-search-bar__icon">search</mat-icon>
|
||||||
|
<input
|
||||||
|
class="sc-search-bar__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter Savings Account ID or Account No."
|
||||||
|
[(ngModel)]="accountInput"
|
||||||
|
(keyup.enter)="searchAccount()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="pageState === 'searching' || !accountInput.trim()"
|
||||||
|
(click)="searchAccount()"
|
||||||
|
>
|
||||||
|
<mat-icon>manage_search</mat-icon>
|
||||||
|
{{ pageState === 'searching' ? 'Searching…' : 'Search Account' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── ERROR ── -->
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="sc-alert sc-alert--error">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── SEARCHING STATE ── -->
|
||||||
|
@if (pageState === 'searching' && isCreate) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
|
||||||
|
<p>Loading account…</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── ACCOUNT CARD (shown when account loaded) ── -->
|
||||||
|
@if (account && (pageState === 'loaded' || pageState === 'submitting' || isEdit || isReview)) {
|
||||||
|
<div class="wdr-account-card">
|
||||||
|
<div class="wdr-account-card__avatar">
|
||||||
|
<mat-icon>account_balance_wallet</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="wdr-account-card__info">
|
||||||
|
<div class="wdr-account-card__name">{{ account.clientName }}</div>
|
||||||
|
<div class="wdr-account-card__meta">
|
||||||
|
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
|
||||||
|
<span class="sc-badge sc-badge--product">{{ account.accountType }}</span>
|
||||||
|
@if (account.status) {
|
||||||
|
<span class="sc-badge sc-badge--active">{{ account.status }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (account.balance != null) {
|
||||||
|
<div class="wdr-account-card__balance">
|
||||||
|
<span class="wdr-account-card__balance-label">Available Balance</span>
|
||||||
|
<span class="wdr-account-card__balance-value" [class.wdr-balance--low]="(amount || 0) > account.balance">
|
||||||
|
{{ account.currency ?? '₹' }}{{ account.balance | number: '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── TRANSACTION DETAILS FORM ── -->
|
||||||
|
@if (account && (pageState === 'loaded' || pageState === 'submitting' || isEdit || isReview)) {
|
||||||
|
<div class="pg-card wdr-form-card">
|
||||||
|
<div class="wdr-form-card__title">Transaction Details</div>
|
||||||
|
|
||||||
|
<div class="wdr-form-grid">
|
||||||
|
<!-- Amount -->
|
||||||
|
<label class="pg-field wdr-form-grid__item wdr-form-grid__item--wide">
|
||||||
|
<span>Withdrawal Amount <span class="wdr-required">*</span></span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="wdr-amount-input">
|
||||||
|
<span class="wdr-amount-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="0.01"
|
||||||
|
[(ngModel)]="amount"
|
||||||
|
[class.wdr-input--error]="validationErrors['amount']"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (validationErrors['amount']) {
|
||||||
|
<span class="wdr-field-error">{{ validationErrors['amount'] }}</span>
|
||||||
|
}
|
||||||
|
@if (amount && amount > 0 && account.balance != null) {
|
||||||
|
<span class="wdr-balance-after" [class.wdr-balance-after--neg]="balanceAfterWithdrawal < 0">
|
||||||
|
Balance after: ₹{{ balanceAfterWithdrawal | number: '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value wdr-readonly-value--amount">₹{{ amount | number: '1.2-2' }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Transaction Date -->
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Transaction Date <span class="wdr-required">*</span></span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<input type="date" [(ngModel)]="txnDate" [class.wdr-input--error]="validationErrors['txnDate']" />
|
||||||
|
@if (validationErrors['txnDate']) {
|
||||||
|
<span class="wdr-field-error">{{ validationErrors['txnDate'] }}</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value">{{ txnDate }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Payment Type -->
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Payment Mode</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<select class="pg-select" [(ngModel)]="paymentTypeId">
|
||||||
|
<option [ngValue]="null">Cash (Default)</option>
|
||||||
|
@for (pt of paymentTypes; track pt.id) {
|
||||||
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value">{{ resolvePaymentTypeName() }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Token No -->
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Token No</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="tokNo"
|
||||||
|
placeholder="Today's token number"
|
||||||
|
[class.wdr-input--error]="validationErrors['tokNo']"
|
||||||
|
/>
|
||||||
|
@if (validationErrors['tokNo']) {
|
||||||
|
<span class="wdr-field-error">{{ validationErrors['tokNo'] }}</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value">{{ tokNo || '—' }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<label class="pg-field wdr-form-grid__item--wide">
|
||||||
|
<span>Narration / Purpose</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="description"
|
||||||
|
placeholder="e.g. Withdrawal for business expenses"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value">{{ description || '—' }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Cheque No (conditional) -->
|
||||||
|
@if (isChequePayment || chequeNo) {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Cheque No</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<input type="text" [(ngModel)]="chequeNo" placeholder="Cheque number" />
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value">{{ chequeNo || '—' }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Bank Name</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<input type="text" [(ngModel)]="bankName" placeholder="Bank name" />
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-readonly-value">{{ bankName || '—' }}</div>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DENOMINATION SECTION ── -->
|
||||||
|
@if (isCashPayment && allDenominations.length > 0) {
|
||||||
|
<div class="pg-card wdr-denom-card">
|
||||||
|
<div class="wdr-denom-card__header">
|
||||||
|
<div class="wdr-denom-card__title">
|
||||||
|
<mat-icon>local_atm</mat-icon>
|
||||||
|
<span>Denomination Breakdown</span>
|
||||||
|
</div>
|
||||||
|
@if (!isReview) {
|
||||||
|
<div
|
||||||
|
class="wdr-denom-card__total"
|
||||||
|
[class.wdr-denom-card__total--ok]="denomMatchesAmount && (amount || 0) > 0"
|
||||||
|
[class.wdr-denom-card__total--err]="!denomMatchesAmount && denominationTotal > 0"
|
||||||
|
>
|
||||||
|
<span>Total: ₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||||
|
@if ((amount || 0) > 0 && denominationTotal > 0) {
|
||||||
|
@if (denomMatchesAmount) {
|
||||||
|
<mat-icon class="wdr-denom-ok">check_circle</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<mat-icon class="wdr-denom-err">error</mat-icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="wdr-denom-card__total">Total: ₹{{ denominationTotal | number: '1.2-2' }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (validationErrors['denom']) {
|
||||||
|
<div class="wdr-field-error wdr-field-error--block">{{ validationErrors['denom'] }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (notes.length > 0) {
|
||||||
|
<div class="wdr-denom-section-label">Notes</div>
|
||||||
|
<div class="wdr-denom-grid">
|
||||||
|
@for (d of notes; track d.id) {
|
||||||
|
<div class="wdr-denom-row">
|
||||||
|
<span class="pg-chip">{{ d.label }}</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wdr-denom-btn"
|
||||||
|
(click)="bumpDenom(d, -1)"
|
||||||
|
[disabled]="countFor(d) === 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="wdr-denom-count"
|
||||||
|
[value]="countFor(d)"
|
||||||
|
(change)="onDenomChange(d, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button type="button" class="wdr-denom-btn" (click)="bumpDenom(d, 1)">+</button>
|
||||||
|
} @else {
|
||||||
|
<span class="wdr-denom-count-ro">{{ countFor(d) }}</span>
|
||||||
|
}
|
||||||
|
<span class="wdr-denom-subtotal">₹{{ subtotalFor(d) | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (coins.length > 0) {
|
||||||
|
<div class="wdr-denom-section-label">Coins</div>
|
||||||
|
<div class="wdr-denom-grid">
|
||||||
|
@for (d of coins; track d.id) {
|
||||||
|
<div class="wdr-denom-row">
|
||||||
|
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wdr-denom-btn"
|
||||||
|
(click)="bumpDenom(d, -1)"
|
||||||
|
[disabled]="countFor(d) === 0"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="wdr-denom-count"
|
||||||
|
[value]="countFor(d)"
|
||||||
|
(change)="onDenomChange(d, $any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button type="button" class="wdr-denom-btn" (click)="bumpDenom(d, 1)">+</button>
|
||||||
|
} @else {
|
||||||
|
<span class="wdr-denom-count-ro">{{ countFor(d) }}</span>
|
||||||
|
}
|
||||||
|
<span class="wdr-denom-subtotal">₹{{ subtotalFor(d) | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="wdr-denom-footer">
|
||||||
|
<button type="button" class="pg-btn wdr-denom-clear" (click)="clearDenominations()">
|
||||||
|
<mat-icon>clear_all</mat-icon> Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── ACTIVITY LOG ── -->
|
||||||
|
@if (activityLog.length > 0) {
|
||||||
|
<div class="pg-card wdr-activity-card">
|
||||||
|
<div class="wdr-activity-card__title">
|
||||||
|
<mat-icon>history</mat-icon>
|
||||||
|
<span>Activity Log</span>
|
||||||
|
</div>
|
||||||
|
<div class="wdr-activity-timeline">
|
||||||
|
@for (entry of activityLog; track $index) {
|
||||||
|
<div class="wdr-activity-item" [ngClass]="activityClass(entry.action)">
|
||||||
|
<div class="wdr-activity-dot"></div>
|
||||||
|
<div class="wdr-activity-body">
|
||||||
|
<div class="wdr-activity-action">{{ activityLabel(entry.action) }}</div>
|
||||||
|
<div class="wdr-activity-meta">
|
||||||
|
<span class="wdr-activity-user">by {{ entry.user }}</span>
|
||||||
|
<span class="wdr-activity-time">{{ formatActivityTime(entry.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
@if (entry.note) {
|
||||||
|
<div class="wdr-activity-note">{{ entry.note }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── ACTION BUTTONS ── -->
|
||||||
|
<div class="wdr-actions">
|
||||||
|
@if (!isReview) {
|
||||||
|
<!-- Clerk Submit / Resubmit -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary wdr-btn-submit"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="pageState === 'submitting'"
|
||||||
|
>
|
||||||
|
<mat-icon>send</mat-icon>
|
||||||
|
{{ submitLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">Cancel</button>
|
||||||
|
} @else {
|
||||||
|
<!-- Manager Approve / Reject -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn wdr-btn-approve"
|
||||||
|
(click)="approve()"
|
||||||
|
[disabled]="pageState === 'submitting'"
|
||||||
|
>
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
{{ pageState === 'submitting' ? 'Processing…' : 'Approve Withdrawal' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn wdr-btn-reject"
|
||||||
|
(click)="openRejectDialog()"
|
||||||
|
[disabled]="pageState === 'submitting'"
|
||||||
|
>
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">Back to List</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── REJECT DIALOG OVERLAY ── -->
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="wdr-overlay" (click)="cancelReject()">
|
||||||
|
<div class="wdr-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="wdr-dialog__header">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
<span>Reject Withdrawal Request</span>
|
||||||
|
</div>
|
||||||
|
<div class="wdr-dialog__body">
|
||||||
|
<p>Provide a reason for rejection. The clerk will see this reason and can edit and resubmit.</p>
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Rejection Reason <span class="wdr-required">*</span></span>
|
||||||
|
<textarea
|
||||||
|
class="wdr-reason-input"
|
||||||
|
[(ngModel)]="rejectionReason"
|
||||||
|
rows="4"
|
||||||
|
placeholder="e.g. Supporting documents missing, amount exceeds limit…"
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
|
<span class="wdr-char-count">{{ rejectionReason.length }}/500</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="wdr-dialog__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn wdr-btn-reject"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
[disabled]="!rejectionReason.trim() || pageState === 'submitting'"
|
||||||
|
>
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Confirm Rejection
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelReject()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── IDLE STATE (no account yet) ── -->
|
||||||
|
@if (isCreate && pageState === 'idle' && !account) {
|
||||||
|
<div class="pg-card wdr-idle-hint">
|
||||||
|
<mat-icon class="wdr-idle-hint__icon">account_balance_wallet</mat-icon>
|
||||||
|
<p>Search for a savings account to start a withdrawal request.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success State ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-success {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 40px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
color: #4ade80 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rejection Banner ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-rejection-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #991b1b;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 2px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account Card ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-account-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #0f4f3c, #14785a);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__balance {
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__balance-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__balance-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #16a34a;
|
||||||
|
|
||||||
|
&.wdr-balance--low {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form Card ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-form-card {
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-required {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-amount-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.wdr-input--error {
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-amount-prefix {
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-right: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-amount-input input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-field input.wdr-input--error {
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-field-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
&--block {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-balance-after {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #16a34a;
|
||||||
|
margin-top: 3px;
|
||||||
|
|
||||||
|
&--neg {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-readonly-value {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
min-height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&--amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Denomination Card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-denom-card {
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--err {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-ok {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-err {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f4f3c;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-count {
|
||||||
|
width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #0d1b2a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-count-ro {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-subtotal {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-denom-clear {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity Log ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-activity-card {
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
border-left: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 16px 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9px;
|
||||||
|
top: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #cbd5e1;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px #cbd5e1;
|
||||||
|
|
||||||
|
.wdr-activity--submitted & {
|
||||||
|
background: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity--resubmitted & {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 2px #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity--approved & {
|
||||||
|
background: #16a34a;
|
||||||
|
box-shadow: 0 0 0 2px #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity--rejected & {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 0 2px #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-action {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
.wdr-activity--submitted & {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity--resubmitted & {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity--approved & {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity--rejected & {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-user {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-activity-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action Buttons ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-btn-submit {
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 42px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-btn-approve {
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 42px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-btn-reject {
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 42px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject Dialog Overlay ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 45%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-dialog {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 60px rgb(0 0 0 / 20%);
|
||||||
|
max-width: 480px;
|
||||||
|
width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-bottom: 1px solid #fecaca;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #991b1b;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-reason-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #dc2626;
|
||||||
|
box-shadow: 0 0 0 3px rgb(220 38 38 / 10%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wdr-char-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Idle Hint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.wdr-idle-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
|
import { ClerkWithdrawalService } from '../../cashier/clerk-withdrawal.service';
|
||||||
|
|
||||||
|
export type WdMode = 'create' | 'edit' | 'review';
|
||||||
|
|
||||||
|
interface Denomination {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
kind: 'note' | 'coin';
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-withdrawal-request',
|
||||||
|
templateUrl: './withdrawal-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'./withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class WithdrawalRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private wdService = inject(ClerkWithdrawalService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
mode: WdMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
existingRequest: any = null;
|
||||||
|
|
||||||
|
pageState: PageState = 'idle';
|
||||||
|
accountInput = '';
|
||||||
|
account: any = null;
|
||||||
|
errorMsg = '';
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
amount: number | null = null;
|
||||||
|
txnDate = '';
|
||||||
|
paymentTypeId: number | null = null;
|
||||||
|
description = '';
|
||||||
|
chequeNo = '';
|
||||||
|
bankName = '';
|
||||||
|
tokNo = '';
|
||||||
|
|
||||||
|
// Denomination
|
||||||
|
paymentTypes: any[] = [];
|
||||||
|
notes: Denomination[] = [];
|
||||||
|
coins: Denomination[] = [];
|
||||||
|
denominationCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
// Reject dialog
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
|
||||||
|
// Activity log
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
validationErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
get isCashPayment(): boolean {
|
||||||
|
if (!this.paymentTypeId) return true;
|
||||||
|
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
||||||
|
return !pt || !!pt.isCashPayment;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isChequePayment(): boolean {
|
||||||
|
if (!this.paymentTypeId) return false;
|
||||||
|
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
||||||
|
return pt?.name?.toLowerCase().includes('cheque') || pt?.name?.toLowerCase().includes('check') || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get allDenominations(): Denomination[] {
|
||||||
|
return [
|
||||||
|
...this.notes,
|
||||||
|
...this.coins
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get denominationTotal(): number {
|
||||||
|
return this.allDenominations.reduce((s, d) => s + this.countFor(d) * d.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get denomMatchesAmount(): boolean {
|
||||||
|
return this.denominationTotal === (this.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get balanceAfterWithdrawal(): number {
|
||||||
|
return (this.account?.balance ?? 0) - (this.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (!this.amount || this.amount <= 0) return false;
|
||||||
|
if (!this.txnDate) return false;
|
||||||
|
if (this.account?.balance != null && (this.amount || 0) > this.account.balance) return false;
|
||||||
|
if (this.isCashPayment && this.allDenominations.length > 0 && !this.denomMatchesAmount) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
get isEdit(): boolean {
|
||||||
|
return this.mode === 'edit';
|
||||||
|
}
|
||||||
|
get isCreate(): boolean {
|
||||||
|
return this.mode === 'create';
|
||||||
|
}
|
||||||
|
|
||||||
|
get submitLabel(): string {
|
||||||
|
if (this.pageState === 'submitting') return 'Submitting…';
|
||||||
|
return this.mode === 'edit' ? 'Resubmit for Approval' : 'Submit for Approval';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
if (this.isReview) return 'Review Withdrawal Request';
|
||||||
|
if (this.isEdit) return 'Edit Withdrawal Request';
|
||||||
|
return 'New Withdrawal Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
if (this.isReview) return "Approve or reject the clerk's withdrawal request.";
|
||||||
|
if (this.isEdit) return 'Update the rejected request and resubmit for approval.';
|
||||||
|
return 'Submit a cash withdrawal request for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = (this.route.snapshot.data?.['mode'] as WdMode) ?? 'create';
|
||||||
|
const idParam = this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.txnDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getPaymentTypes()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((types: any[]) => (this.paymentTypes = types || []));
|
||||||
|
|
||||||
|
this.loadDenominations();
|
||||||
|
|
||||||
|
if (this.requestId && (this.mode === 'edit' || this.mode === 'review')) {
|
||||||
|
this.loadRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadDenominations(): void {
|
||||||
|
this.cashierService
|
||||||
|
.getActiveDenominations()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((list: any[]) => {
|
||||||
|
const rows: Denomination[] = (list || []).map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
value: d.denomination ?? d.value,
|
||||||
|
kind: d.type === 'coin' ? 'coin' : 'note',
|
||||||
|
label: '₹' + (d.denomination ?? d.value)
|
||||||
|
}));
|
||||||
|
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
||||||
|
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
||||||
|
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadRequest(id: number): void {
|
||||||
|
this.pageState = 'searching';
|
||||||
|
this.wdService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.existingRequest = req;
|
||||||
|
this.amount = Number(req.amount);
|
||||||
|
this.txnDate = req.txn_date ? String(req.txn_date).split('T')[0].split(' ')[0] : this.txnDate;
|
||||||
|
this.paymentTypeId = req.payment_type_id ? Number(req.payment_type_id) : null;
|
||||||
|
this.description = req.description ?? '';
|
||||||
|
this.chequeNo = req.cheque_no ?? '';
|
||||||
|
this.bankName = req.bank_name ?? '';
|
||||||
|
this.tokNo = req.tok_no ?? '';
|
||||||
|
|
||||||
|
if (req.denomination_data) {
|
||||||
|
try {
|
||||||
|
this.denominationCounts = JSON.parse(req.denomination_data);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.account = {
|
||||||
|
id: req.savings_account_id ? Number(req.savings_account_id) : null,
|
||||||
|
accountNo: req.account_no,
|
||||||
|
clientName: req.client_name,
|
||||||
|
clientId: req.client_id ? Number(req.client_id) : null,
|
||||||
|
accountType: req.account_type,
|
||||||
|
balance: null
|
||||||
|
};
|
||||||
|
this.accountInput = req.account_no;
|
||||||
|
|
||||||
|
// Load live balance from savings account if ID is available
|
||||||
|
if (this.account.id) {
|
||||||
|
this.http
|
||||||
|
.get<any>(`/savingsaccounts/${this.account.id}`)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
if (acc) {
|
||||||
|
this.account.balance = Number(acc.summary?.availableBalance ?? acc.summary?.accountBalance ?? 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageState = 'loaded';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.snackBar.open('Failed to load withdrawal request.', 'Close', { duration: 5000 });
|
||||||
|
this.pageState = 'idle';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchAccount(): void {
|
||||||
|
const query = this.accountInput.trim();
|
||||||
|
if (!query) return;
|
||||||
|
this.pageState = 'searching';
|
||||||
|
this.account = null;
|
||||||
|
this.errorMsg = '';
|
||||||
|
|
||||||
|
const byId$ = /^\d+$/.test(query)
|
||||||
|
? this.http.get<any>(`/savingsaccounts/${query}`).pipe(catchError(() => of(null)))
|
||||||
|
: of(null);
|
||||||
|
|
||||||
|
byId$
|
||||||
|
.pipe(
|
||||||
|
switchMap((acc: any) => {
|
||||||
|
if (acc && acc.id) return of(acc);
|
||||||
|
return this.http
|
||||||
|
.get<any[]>(`/search?query=${encodeURIComponent(query)}&resource=savingsaccount`)
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.pipe(
|
||||||
|
map((results: any[]) => (results || []).find((r: any) => r.entityId)),
|
||||||
|
switchMap((r: any) =>
|
||||||
|
r?.entityId
|
||||||
|
? this.http.get<any>(`/savingsaccounts/${r.entityId}`).pipe(catchError(() => of(null)))
|
||||||
|
: of(null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (acc: any) => {
|
||||||
|
if (!acc || !acc.id) {
|
||||||
|
this.errorMsg = 'Account not found. Please check the account number or ID.';
|
||||||
|
this.pageState = 'idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.account = {
|
||||||
|
id: acc.id,
|
||||||
|
accountNo: acc.accountNo,
|
||||||
|
clientName: acc.clientName ?? acc.summary?.clientName,
|
||||||
|
clientId: acc.clientId,
|
||||||
|
accountType: acc.depositType?.value ?? 'SAVINGS',
|
||||||
|
balance: Number(acc.summary?.availableBalance ?? acc.summary?.accountBalance ?? 0),
|
||||||
|
currency: acc.currency?.displaySymbol ?? '₹',
|
||||||
|
status: acc.status?.value ?? 'Active',
|
||||||
|
productName: acc.savingsProductName ?? ''
|
||||||
|
};
|
||||||
|
this.pageState = 'loaded';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Denomination helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
denomKey(d: Denomination): string {
|
||||||
|
return `${d.kind}:${d.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
countFor(d: Denomination): number {
|
||||||
|
return this.denominationCounts[this.denomKey(d)] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtotalFor(d: Denomination): number {
|
||||||
|
return this.countFor(d) * d.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDenomChange(d: Denomination, raw: any): void {
|
||||||
|
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpDenom(d: Denomination, delta: number): void {
|
||||||
|
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDenominations(): void {
|
||||||
|
this.denominationCounts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
validate(): boolean {
|
||||||
|
this.validationErrors = {};
|
||||||
|
if (!this.account) {
|
||||||
|
this.validationErrors['account'] = 'Account is required. Please search and select an account.';
|
||||||
|
}
|
||||||
|
if (!this.amount || this.amount <= 0) {
|
||||||
|
this.validationErrors['amount'] = 'Amount must be greater than zero.';
|
||||||
|
}
|
||||||
|
if (this.account?.balance != null && (this.amount || 0) > this.account.balance) {
|
||||||
|
this.validationErrors['amount'] = `Insufficient balance. Available: ₹${this.account.balance.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
if (!this.txnDate) {
|
||||||
|
this.validationErrors['txnDate'] = 'Transaction date is required.';
|
||||||
|
}
|
||||||
|
if (this.isCashPayment && this.allDenominations.length > 0 && !this.denomMatchesAmount) {
|
||||||
|
this.validationErrors['denom'] =
|
||||||
|
`Denomination total (₹${this.denominationTotal}) does not match amount (₹${this.amount}).`;
|
||||||
|
}
|
||||||
|
return Object.keys(this.validationErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.validate() || this.pageState === 'submitting') return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
accountNo: this.account.accountNo,
|
||||||
|
accountType: this.account.accountType || 'SAVINGS',
|
||||||
|
savingsAccountId: this.account.id,
|
||||||
|
clientId: this.account.clientId,
|
||||||
|
clientName: this.account.clientName,
|
||||||
|
amount: this.amount,
|
||||||
|
txnDate: this.txnDate,
|
||||||
|
paymentTypeId: this.paymentTypeId || null,
|
||||||
|
paymentTypeName: this.paymentTypes.find((p) => p.id === this.paymentTypeId)?.name ?? 'Cash',
|
||||||
|
description: this.description || null,
|
||||||
|
chequeNo: this.chequeNo || null,
|
||||||
|
bankName: this.bankName || null,
|
||||||
|
tokNo: this.tokNo?.trim() || null,
|
||||||
|
denominationData: JSON.stringify(this.denominationCounts)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pageState = 'submitting';
|
||||||
|
|
||||||
|
const request$ =
|
||||||
|
this.mode === 'edit' && this.requestId
|
||||||
|
? this.wdService.update(this.requestId, payload)
|
||||||
|
: this.wdService.submit(payload);
|
||||||
|
|
||||||
|
request$.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.pageState = 'success';
|
||||||
|
const msg =
|
||||||
|
this.mode === 'edit' ? 'Request resubmitted for approval.' : 'Withdrawal request submitted for approval.';
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 3000 });
|
||||||
|
setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1500);
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
const msg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
err?.error?.developerMessage ||
|
||||||
|
'Failed to submit request. Please try again.';
|
||||||
|
if (/token/i.test(msg)) {
|
||||||
|
this.validationErrors['tokNo'] = msg;
|
||||||
|
}
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
this.pageState = 'loaded';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.rejectionReason = '';
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReject(): void {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(): void {
|
||||||
|
if (!this.requestId || this.pageState === 'submitting') return;
|
||||||
|
this.pageState = 'submitting';
|
||||||
|
this.wdService.approve(this.requestId).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.pageState = 'success';
|
||||||
|
this.snackBar.open('Withdrawal request approved successfully.', 'Close', { duration: 3000 });
|
||||||
|
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1500);
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
const msg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'Approval failed. Please try again.';
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
this.pageState = 'loaded';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.requestId || !this.rejectionReason.trim() || this.pageState === 'submitting') return;
|
||||||
|
this.pageState = 'submitting';
|
||||||
|
this.wdService.reject(this.requestId, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.pageState = 'success';
|
||||||
|
this.snackBar.open('Withdrawal request rejected.', 'Close', { duration: 3000 });
|
||||||
|
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1500);
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
const msg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'Rejection failed. Please try again.';
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
this.pageState = 'loaded';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.isReview) {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/withdrawal-requests']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity log helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
activityLabel(action: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
SUBMITTED: 'Submitted',
|
||||||
|
RESUBMITTED: 'Resubmitted',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return labels[action] || action;
|
||||||
|
}
|
||||||
|
|
||||||
|
activityClass(action: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
SUBMITTED: 'wdr-activity--submitted',
|
||||||
|
RESUBMITTED: 'wdr-activity--resubmitted',
|
||||||
|
APPROVED: 'wdr-activity--approved',
|
||||||
|
REJECTED: 'wdr-activity--rejected'
|
||||||
|
};
|
||||||
|
return map[action] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePaymentTypeName(): string {
|
||||||
|
if (!this.paymentTypeId) return 'Cash';
|
||||||
|
return this.paymentTypes.find((p) => p.id === this.paymentTypeId)?.name ?? 'Cash';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatActivityTime(ts: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.wr {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wr-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirrors the clerk dashboard nav (shell .cashier-nav) for identical look & feel */
|
||||||
|
.cashier-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
background: #1a2744;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-bottom: 2px solid #0ea5e9;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #223060;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #0ea5e9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: #1353a0;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1a6ac8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cashier-nav__item--active {
|
||||||
|
background: #1353a0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sec {
|
||||||
|
font-size: 11.5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__esc-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #2a3a5e;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #0ea5e9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--clerk {
|
||||||
|
border-bottom-color: #e87722;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { TransactionEntryComponent } from '../../cashier/transaction-entry/transaction-entry.component';
|
||||||
|
import { AccountFacilitiesComponent } from '../account-facilities/account-facilities.component';
|
||||||
|
import { PhotoSignatureComponent } from '../photo-signature/photo-signature.component';
|
||||||
|
import { StandingInstructionsComponent } from '../standing-instructions/standing-instructions.component';
|
||||||
|
import { TodayActivityComponent } from '../today-activity/today-activity.component';
|
||||||
|
|
||||||
|
type ReviewTab = 'dashboard' | 'facilities' | 'photo' | 'si' | 'activity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager review wrapper — full-screen, self-contained. Renders the same workspace + nav the clerk
|
||||||
|
* sees, pre-filled from the withdrawal request, with Approve/Reject. The header tabs switch panels
|
||||||
|
* in-page (no navigation away) and show the reviewed account's data via shared clerk state.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-withdrawal-review',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TransactionEntryComponent,
|
||||||
|
AccountFacilitiesComponent,
|
||||||
|
PhotoSignatureComponent,
|
||||||
|
StandingInstructionsComponent,
|
||||||
|
TodayActivityComponent
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="wr">
|
||||||
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cashier-nav__item cashier-nav__item--primary"
|
||||||
|
[class.cashier-nav__item--active]="activeTab === 'dashboard'"
|
||||||
|
(click)="activeTab = 'dashboard'"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<circle cx="8" cy="10" r="2" />
|
||||||
|
<path d="M14 9h4M14 13h4M5 15h7" />
|
||||||
|
</svg>
|
||||||
|
Withdrawal Review
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="activeTab === 'facilities'"
|
||||||
|
(click)="activeTab = 'facilities'"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
|
</svg>
|
||||||
|
Account Facilities
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="activeTab === 'photo'"
|
||||||
|
(click)="activeTab = 'photo'"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
||||||
|
</svg>
|
||||||
|
Photo & Signature
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="activeTab === 'si'"
|
||||||
|
(click)="activeTab = 'si'"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="17 1 21 5 17 9" />
|
||||||
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<polyline points="7 23 3 19 7 15" />
|
||||||
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
Standing Instructions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="activeTab === 'activity'"
|
||||||
|
(click)="activeTab = 'activity'"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
Today's Activity
|
||||||
|
</button>
|
||||||
|
<span class="cashier-nav__esc-hint" (click)="backToInbox()"> ← Back to Inbox </span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wr-body">
|
||||||
|
<!-- Dashboard stays mounted so it loads once and keeps shared state fresh -->
|
||||||
|
<div [hidden]="activeTab !== 'dashboard'">
|
||||||
|
<mifosx-transaction-entry [clerkMode]="true" [reviewRequestId]="requestId"></mifosx-transaction-entry>
|
||||||
|
</div>
|
||||||
|
@if (activeTab === 'facilities') {
|
||||||
|
<mifosx-account-facilities [embedded]="true"></mifosx-account-facilities>
|
||||||
|
}
|
||||||
|
@if (activeTab === 'photo') {
|
||||||
|
<mifosx-photo-signature [embedded]="true"></mifosx-photo-signature>
|
||||||
|
}
|
||||||
|
@if (activeTab === 'si') {
|
||||||
|
<mifosx-standing-instructions [embedded]="true"></mifosx-standing-instructions>
|
||||||
|
}
|
||||||
|
@if (activeTab === 'activity') {
|
||||||
|
<mifosx-today-activity [embedded]="true"></mifosx-today-activity>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './withdrawal-review.component.scss'
|
||||||
|
})
|
||||||
|
export class WithdrawalReviewComponent {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requestId: number | null = null;
|
||||||
|
activeTab: ReviewTab = 'dashboard';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const id = this.route.snapshot.params['id'];
|
||||||
|
this.requestId = id != null ? Number(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
backToInbox(): void {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
<!-- Toolbar always visible (shows CBSNexora, Branch, Session info) -->
|
<!-- Toolbar always visible (shows CBSNexora, Branch, Session info) -->
|
||||||
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
|
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
|
||||||
|
|
||||||
<!-- Simplified menu bar — cashier OR clerk fullscreen mode -->
|
<!-- Simplified menu bar — cashier OR clerk fullscreen mode. The review screen renders its own nav. -->
|
||||||
@if (hideShellLayout) {
|
@if (hideShellLayout && !isWithdrawalReviewUrl) {
|
||||||
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/dashboard -->
|
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
||||||
@if (currentUrl.includes('/clerk/')) {
|
@if (currentUrl.includes('/clerk/')) {
|
||||||
<nav class="cashier-nav cashier-nav--clerk">
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
<a
|
<a
|
||||||
@@ -69,6 +69,19 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="id-card"></fa-icon> Clerk Dashboard
|
<fa-icon icon="id-card"></fa-icon> Clerk Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/withdrawal-request')"
|
||||||
|
[routerLink]="['/clerk/withdrawal-requests']"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="9" y1="14" x2="15" y2="14" />
|
||||||
|
<line x1="9" y1="18" x2="13" y2="18" />
|
||||||
|
</svg>
|
||||||
|
My Requests
|
||||||
|
</a>
|
||||||
@if (clerkAccountLoaded) {
|
@if (clerkAccountLoaded) {
|
||||||
<a
|
<a
|
||||||
class="cashier-nav__item cashier-nav__item--sec"
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
url.includes('/clerk/account-facilities') ||
|
url.includes('/clerk/account-facilities') ||
|
||||||
url.includes('/clerk/photo-signature') ||
|
url.includes('/clerk/photo-signature') ||
|
||||||
url.includes('/clerk/standing-instructions') ||
|
url.includes('/clerk/standing-instructions') ||
|
||||||
url.includes('/clerk/today-activity');
|
url.includes('/clerk/today-activity') ||
|
||||||
this.hideShellLayout = isCashierOnly || isClerkRoute;
|
url.includes('/clerk/withdrawal-request');
|
||||||
|
this.hideShellLayout = isCashierOnly || isClerkRoute || this.isWithdrawalReviewUrl;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
};
|
};
|
||||||
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
||||||
@@ -167,10 +168,15 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
return this.router.url;
|
return this.router.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESC key — exit fullscreen at Clerk Dashboard, restoring full shell header. */
|
/** True on the manager's full-screen withdrawal-request review screen. */
|
||||||
|
get isWithdrawalReviewUrl(): boolean {
|
||||||
|
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ESC key — exit fullscreen at Clerk Dashboard / review screen, restoring full shell header. */
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscapeKey(): void {
|
onEscapeKey(): void {
|
||||||
if (this.hideShellLayout && this.router.url.includes('/clerk/')) {
|
if (this.hideShellLayout && (this.router.url.includes('/clerk/') || this.isWithdrawalReviewUrl)) {
|
||||||
this.hideShellLayout = false;
|
this.hideShellLayout = false;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
<!--
|
||||||
|
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="cv-tab-content wri-tab">
|
||||||
|
<!-- ── TITLE BAR ── -->
|
||||||
|
<div class="wri-action-bar">
|
||||||
|
<div class="wri-action-bar__left">
|
||||||
|
<mat-icon class="wri-action-bar__icon">account_balance_wallet</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="wri-action-bar__title">Withdrawal Requests</div>
|
||||||
|
<div class="wri-action-bar__sub">Review, approve or reject clerk withdrawal requests.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── FILTER BAR ── -->
|
||||||
|
<div class="wri-filter-bar">
|
||||||
|
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span class="wri-fb-sep"></span>
|
||||||
|
|
||||||
|
<div class="wri-presets">
|
||||||
|
@for (p of datePresets; track p.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wri-preset-chip"
|
||||||
|
[class.wri-preset-chip--active]="activePreset === p.key"
|
||||||
|
(click)="applyPreset(p.key)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="wri-fb-sep"></span>
|
||||||
|
|
||||||
|
<div class="wri-date-range">
|
||||||
|
<div class="wri-date-box">
|
||||||
|
<span class="wri-date-box__lbl">From</span>
|
||||||
|
<input
|
||||||
|
class="wri-date-box__input"
|
||||||
|
[matDatepicker]="fromPicker"
|
||||||
|
[(ngModel)]="fromDate"
|
||||||
|
[max]="maxDate"
|
||||||
|
(dateChange)="onFromDateChange()"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button type="button" class="wri-date-box__btn" (click)="fromPicker.open()">
|
||||||
|
<mat-icon>event</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-datepicker #fromPicker></mat-datepicker>
|
||||||
|
</div>
|
||||||
|
<span class="wri-date-range__sep">—</span>
|
||||||
|
<div class="wri-date-box">
|
||||||
|
<span class="wri-date-box__lbl">To</span>
|
||||||
|
<input
|
||||||
|
class="wri-date-box__input"
|
||||||
|
[matDatepicker]="toPicker"
|
||||||
|
[(ngModel)]="toDate"
|
||||||
|
[min]="fromDate"
|
||||||
|
[max]="maxDate"
|
||||||
|
(dateChange)="onToDateChange()"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button type="button" class="wri-date-box__btn" (click)="toPicker.open()">
|
||||||
|
<mat-icon>event</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-datepicker #toPicker></mat-datepicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TABLE ── -->
|
||||||
|
@if (loading) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-title">Loading…</p>
|
||||||
|
</div>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-inbox empty-icon"></i>
|
||||||
|
<p class="empty-title">No withdrawal requests</p>
|
||||||
|
<p class="empty-text">
|
||||||
|
@if (filterStatus === 'PENDING') {
|
||||||
|
There are no pending withdrawal requests waiting for review.
|
||||||
|
} @else {
|
||||||
|
No {{ filterStatus | lowercase }} withdrawal requests found.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="wri-table-wrap">
|
||||||
|
<table class="wri-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="wri-th-id">#</th>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client Name</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Payment Mode</th>
|
||||||
|
<th>Clerk</th>
|
||||||
|
<th>Reviewed By</th>
|
||||||
|
<th>Cashier / Disbursed</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="wri-th-action">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr class="wri-row" [class.wri-row--disbursed]="r.status === 'DISBURSED'" (click)="review(r)">
|
||||||
|
<td class="cell-mono">{{ r.id }}</td>
|
||||||
|
<td class="cell-mono">{{ r.account_no }}</td>
|
||||||
|
<td class="wri-client">{{ r.client_name }}</td>
|
||||||
|
<td class="num wri-amount">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.payment_type_name || 'Cash' }}</td>
|
||||||
|
<td>{{ r.clerk_name }}</td>
|
||||||
|
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||||
|
<td class="wri-cashier-cell">
|
||||||
|
@if (r.status === 'DISBURSED') {
|
||||||
|
<div class="wri-cashier-info">
|
||||||
|
<span class="wri-cashier-name">{{ r.disbursed_by }}</span>
|
||||||
|
<span class="wri-dt">{{ formatDateTime(r.disbursed_at) }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="wri-none">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="wri-dt">{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="wri-status" [ngClass]="statusClass(r.status)">
|
||||||
|
{{ statusLabel(r.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="wri-action-cell" (click)="$event.stopPropagation()">
|
||||||
|
<button type="button" class="wri-btn wri-btn--review" (click)="review(r)">
|
||||||
|
<mat-icon>rate_review</mat-icon>
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
[length]="totalRecords"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onPageChange($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-tab {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-fb-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-filter-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-filter-select {
|
||||||
|
height: 34px;
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-presets {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-preset-chip {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #0f4f3c;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #0f4f3c;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-date-range {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 300;
|
||||||
|
padding: 0 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-date-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 6px 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 76px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f4f3c;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--refresh {
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--review {
|
||||||
|
background: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
background: #f8fafc;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-th-id {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-th-action {
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-action-cell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-client {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-dt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approved {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disbursed {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-row--disbursed td {
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-cashier-cell {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-cashier-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-cashier-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wri-none {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state (re-use checker inbox styles)
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatNativeDateModule } from '@angular/material/core';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkWithdrawalService } from '../../../cashier/clerk-withdrawal.service';
|
||||||
|
import { SettingsService } from '../../../settings/settings.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-withdrawal-requests',
|
||||||
|
templateUrl: './withdrawal-requests.component.html',
|
||||||
|
styleUrls: ['./withdrawal-requests.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatNativeDateModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatDatepickerModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class WithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSizeOptions = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50
|
||||||
|
];
|
||||||
|
|
||||||
|
filterStatus = 'PENDING';
|
||||||
|
fromDate: Date | null = null;
|
||||||
|
toDate: Date | null = null;
|
||||||
|
maxDate!: Date;
|
||||||
|
activePreset: string = 'today';
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
readonly datePresets = [
|
||||||
|
{ key: 'today', label: 'Today' },
|
||||||
|
{ key: 'week', label: 'This Week' },
|
||||||
|
{ key: 'month', label: 'This Month' },
|
||||||
|
{ key: 'all', label: 'All' }
|
||||||
|
];
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' },
|
||||||
|
{ value: 'DISBURSED', label: 'Disbursed' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const today = new Date(this.settingsService.businessDate);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
this.maxDate = today;
|
||||||
|
this.applyPreset('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getPendingRequests(
|
||||||
|
this.filterStatus,
|
||||||
|
this.pageIndex * this.pageSize,
|
||||||
|
this.pageSize,
|
||||||
|
this.isoDate(this.fromDate),
|
||||||
|
this.isoDate(this.toDate)
|
||||||
|
)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: PageEvent): void {
|
||||||
|
this.pageSize = event.pageSize;
|
||||||
|
this.pageIndex = event.pageIndex;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFromDateChange(): void {
|
||||||
|
this.activePreset = '';
|
||||||
|
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||||
|
this.toDate = new Date(this.fromDate);
|
||||||
|
}
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToDateChange(): void {
|
||||||
|
this.activePreset = '';
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(key: string): void {
|
||||||
|
this.activePreset = key;
|
||||||
|
const today = new Date(this.settingsService.businessDate);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
if (key === 'today') {
|
||||||
|
this.fromDate = new Date(today);
|
||||||
|
this.toDate = new Date(today);
|
||||||
|
} else if (key === 'week') {
|
||||||
|
const start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - today.getDay());
|
||||||
|
this.fromDate = start;
|
||||||
|
this.toDate = new Date(today);
|
||||||
|
} else if (key === 'month') {
|
||||||
|
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
this.toDate = new Date(today);
|
||||||
|
} else {
|
||||||
|
this.fromDate = null;
|
||||||
|
this.toDate = null;
|
||||||
|
}
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isoDate(d: Date | null): string | undefined {
|
||||||
|
if (!d) return undefined;
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
review(req: any): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/checker-inbox-and-tasks/withdrawal-requests',
|
||||||
|
req.id,
|
||||||
|
'review'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'wri-status--pending',
|
||||||
|
APPROVED: 'wri-status--approved',
|
||||||
|
REJECTED: 'wri-status--rejected',
|
||||||
|
DISBURSED: 'wri-status--disbursed'
|
||||||
|
};
|
||||||
|
return map[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
DISBURSED: 'Disbursed'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,18 @@
|
|||||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Reject Account</span>
|
<span>Reject Account</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="ac-tab"
|
||||||
|
[routerLink]="['./withdrawal-requests']"
|
||||||
|
routerLinkActive
|
||||||
|
#withdrawalRequests="routerLinkActive"
|
||||||
|
[active]="withdrawalRequests.isActive"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Withdrawal Requests</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<mat-tab-nav-panel #tabPanel>
|
<mat-tab-nav-panel #tabPanel>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Route } from '../core/route/route.service';
|
|||||||
/** Custom Components */
|
/** Custom Components */
|
||||||
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
|
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
|
||||||
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
||||||
|
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
|
||||||
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
||||||
import { LoanApprovalComponent } from './checker-inbox-and-tasks-tabs/loan-approval/loan-approval.component';
|
import { LoanApprovalComponent } from './checker-inbox-and-tasks-tabs/loan-approval/loan-approval.component';
|
||||||
import { LoanDisbursalComponent } from './checker-inbox-and-tasks-tabs/loan-disbursal/loan-disbursal.component';
|
import { LoanDisbursalComponent } from './checker-inbox-and-tasks-tabs/loan-disbursal/loan-disbursal.component';
|
||||||
@@ -101,6 +102,11 @@ const routes: Routes = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
rejectedAccountsData: GetRejectedAccounts
|
rejectedAccountsData: GetRejectedAccounts
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'withdrawal-requests',
|
||||||
|
component: WithdrawalRequestsComponent,
|
||||||
|
data: { title: 'Withdrawal Requests' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -116,6 +122,17 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'checker-inbox-and-tasks/withdrawal-requests',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id/review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/withdrawal-review/withdrawal-review.component').then((m) => m.WithdrawalReviewComponent),
|
||||||
|
data: { title: 'Review Withdrawal Request', breadcrumb: 'Review Withdrawal' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user