feat(cashier,savings,tasks): clerk-manager-cashier account closure flow
Replaces the one-click Close action on the savings account page (removed from the Danger Zone menu) with a maker-checker flow, since closing an account pays real cash out of a teller's drawer. Clerk: an Action button on Transaction Entry, shown once an ACTIVE savings account is loaded. Ticking "Close this account" previews the payout -- balance plus interest calculated to today by the same code the closure itself runs -- and Save drops that figure into the slip's AMOUNT. The clerk then fills TOK. NO. on the slip and presses Save, which raises the request. Token and narration deliberately live on the slip, not in the popup, so the closure is entered like any other transaction. Manager: a Closure Requests tab in the Approval Center, reviewed in a popup dialog (matching Disbursement Requests) rather than a separate route. Cashier: the closure token is recognised at the counter alongside withdrawal and dividend tokens, auto-filling the rounded cash figure, locking it, and closing the account once the denomination is counted. Save-path fixes, all of which silently blocked a valid closure: - amount came from the denomination total whenever clerkMode was false -- which it is for any route other than the Clerk Dashboard wrapper -- so the calculated payout was replaced by zero - the balance check rejected the payout because it legitimately exceeds today's withdrawable amount: it includes interest close() posts before withdrawing. Min-balance, withdrawal-facility and clerk-side denomination checks do not apply to a closure either - on the cashier side the account is already CLOSED, so accountDetails is a stub with no balance and the same check reported "Withdrawable 0.00" - the closure branch had to precede the plain clerk-withdrawal branch: a closure is a full-balance withdrawal, so that branch would have swallowed it and the account would never have closed - onSave's one silent return now says what is missing, instead of leaving Save looking like a broken button Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,14 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||
},
|
||||
{
|
||||
path: 'closure-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-closure-requests/my-closure-requests.component').then(
|
||||
(m) => m.MyClosureRequestsComponent
|
||||
),
|
||||
data: { title: 'My Closure Requests', breadcrumb: 'My Closure Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/** HTTP service for the Clerk Closure Request maker-checker workflow (Cash-only savings account
|
||||
* closure). Mirrors ClerkWithdrawalService's shape against the /clerk/closure-requests endpoints. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkClosureRequestService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/closure-requests', 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/closure-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(
|
||||
status = 'PENDING',
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
search?: 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);
|
||||
if (search) params = params.set('search', search);
|
||||
return this.http.get('/clerk/closure-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/closure-requests/${id}`);
|
||||
}
|
||||
|
||||
/** Preview of what the member would receive on closure. The backend dry-runs the very same
|
||||
* interest posting that closing the account will perform, then rolls it back — so the figure
|
||||
* matches the real payout to the paisa. Nothing is persisted. */
|
||||
preview(savingsAccountId: number): Observable<any> {
|
||||
return this.http.get(`/clerk/closure-requests/preview/${savingsAccountId}`);
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/closure-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/closure-requests/${id}/reject`, { reason });
|
||||
}
|
||||
|
||||
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
|
||||
// pool, could be a withdrawal/dividend claim's instead) and shows its own tailored message either way.
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/closure-requests/by-token/${encodeURIComponent(token)}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||
return this.http.post(`/clerk/closure-requests/${id}/disburse`, {
|
||||
denominationData,
|
||||
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -236,14 +236,14 @@
|
||||
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||
>
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
@if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved request">🔒</span>
|
||||
}
|
||||
</label>
|
||||
<div
|
||||
class="ws-amt-wrap"
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -255,8 +255,8 @@
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
@@ -311,8 +311,8 @@
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.tokNo"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[class.ws-f__ctl--readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
placeholder="Enter token"
|
||||
(blur)="onTokNoBlur(row)"
|
||||
(click)="$event.stopPropagation()"
|
||||
@@ -436,6 +436,73 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (isClerk && pendingClosure) {
|
||||
<div class="ws-withdrawal-banner ws-withdrawal-banner--closure">
|
||||
<mat-icon class="ws-withdrawal-banner__icon">lock</mat-icon>
|
||||
<div class="ws-withdrawal-banner__content">
|
||||
<div class="ws-withdrawal-banner__title">Closing This Account</div>
|
||||
<div class="ws-withdrawal-banner__details">
|
||||
@if (closurePreview) {
|
||||
<span
|
||||
>Balance: <strong>₹{{ closurePreview.balance | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span
|
||||
>Interest: <strong>+₹{{ closurePreview.interest | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span
|
||||
>Payable: <strong>₹{{ closurePreview.total | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-withdrawal-banner__note">
|
||||
Enter <strong>TOK. NO.</strong> below and press <strong>Save</strong> to send this for the Bank Manager's
|
||||
approval. The account closes when the cashier hands over the cash.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ws-withdrawal-banner__clear" (click)="clearPendingClosure()" title="Cancel closure">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!isClerk && activeClosureRequest) {
|
||||
<div class="ws-withdrawal-banner">
|
||||
<mat-icon class="ws-withdrawal-banner__icon">lock</mat-icon>
|
||||
<div class="ws-withdrawal-banner__content">
|
||||
<div class="ws-withdrawal-banner__title">Approved Account Closure Linked</div>
|
||||
<div class="ws-withdrawal-banner__details">
|
||||
<span
|
||||
>Token: <strong>{{ activeClosureRequest.tok_no }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span
|
||||
>Cash to Hand Over:
|
||||
<strong>₹{{ activeClosureRequest.cashier_disbursed_amount | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span
|
||||
>Client: <strong>{{ activeClosureRequest.client_name }}</strong></span
|
||||
>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span>By Clerk: {{ activeClosureRequest.clerk_name }}</span>
|
||||
<span class="ws-wb-sep">·</span>
|
||||
<span>Approved by: {{ activeClosureRequest.manager_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="ws-withdrawal-banner__note">
|
||||
@if (activeClosureRequest.rounding_amount && activeClosureRequest.rounding_amount !== 0) {
|
||||
Exact closing balance was ₹{{ activeClosureRequest.closing_amount | number: '1.2-2' }} — rounded
|
||||
{{ activeClosureRequest.rounding_amount > 0 ? 'up' : 'down' }} to whole rupees for cash.
|
||||
} @else {
|
||||
The account is already closed. Hand over the cash to complete the request.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeClosureRequest = null" title="Unlink">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
|
||||
@if (loadingAccount) {
|
||||
@@ -1443,14 +1510,16 @@
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
@if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved request">🔒</span>
|
||||
}
|
||||
</label>
|
||||
<div
|
||||
class="ws-amt-wrap"
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-wrap--locked]="
|
||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||
"
|
||||
>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -1462,8 +1531,10 @@
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[class.ws-amt-input--locked]="
|
||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
@@ -1504,8 +1575,10 @@
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.tokNo"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||
"
|
||||
placeholder="Enter token"
|
||||
(blur)="onTokNoBlur(row)"
|
||||
/>
|
||||
@@ -1673,6 +1746,19 @@
|
||||
My Disbursements
|
||||
</button>
|
||||
}
|
||||
@if (canShowAccountActions) {
|
||||
<button
|
||||
class="ws-btn ws-btn--action"
|
||||
type="button"
|
||||
(click)="openAccountActions()"
|
||||
title="Account actions for the loaded account"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
Action
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-footer__actions">
|
||||
<button
|
||||
@@ -1694,6 +1780,81 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── ACCOUNT ACTIONS (clerk) ──────────────────────────────────────────── -->
|
||||
@if (showAccountActions) {
|
||||
<div class="te-confirm-overlay" (click)="closeAccountActions()">
|
||||
<div class="te-confirm-box te-aa-box" (click)="$event.stopPropagation()">
|
||||
<div class="te-confirm-header te-confirm-header--action">
|
||||
<span class="te-confirm-icon">⚡</span>
|
||||
<span>Account Actions</span>
|
||||
</div>
|
||||
<div class="te-confirm-body">
|
||||
<div class="te-aa-account">
|
||||
<span class="te-aa-account__no">{{ accountDetails?.accountNo }}</span>
|
||||
<span class="te-aa-account__name">{{ clientName }}</span>
|
||||
</div>
|
||||
|
||||
<label class="te-aa-check">
|
||||
<input type="checkbox" [(ngModel)]="closeAccountChecked" (change)="onCloseAccountToggle()" />
|
||||
<span>
|
||||
<strong>Close this account</strong>
|
||||
<small>Pays out the full balance in cash and closes the account, after manager approval.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@if (closeAccountChecked) {
|
||||
@if (closurePreviewLoading) {
|
||||
<div class="te-aa-calc te-aa-calc--loading">Calculating interest to date…</div>
|
||||
} @else if (closurePreviewError) {
|
||||
<div class="te-aa-calc te-aa-calc--error">{{ closurePreviewError }}</div>
|
||||
} @else if (closurePreview) {
|
||||
<div class="te-aa-calc">
|
||||
<div class="te-aa-calc__row">
|
||||
<span>Current Balance</span>
|
||||
<span>₹{{ closurePreview.balance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-aa-calc__row">
|
||||
<span>Interest to date</span>
|
||||
<span>+ ₹{{ closurePreview.interest | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-aa-calc__row te-aa-calc__row--total">
|
||||
<span>Payable on Closure</span>
|
||||
<span>₹{{ closurePreview.total | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (!closurePreview.upToDate) {
|
||||
<div class="te-aa-calc__warn">
|
||||
Interest could not be calculated just now, so this shows the current balance only — the actual payout
|
||||
will be higher once interest is added at approval.
|
||||
</div>
|
||||
}
|
||||
<div class="te-aa-calc__note">
|
||||
Interest calculated to {{ closurePreview.asOfDate }} using the same calculation the closure itself
|
||||
performs. Cash is handed over rounded to whole rupees.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="te-aa-next">
|
||||
This amount will be filled into <strong>AMOUNT</strong> on the slip. Enter <strong>TOK. NO.</strong> and
|
||||
press <strong>Save</strong> to send it for the Bank Manager's approval.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="te-confirm-actions">
|
||||
<button class="te-btn" type="button" (click)="closeAccountActions()">Cancel</button>
|
||||
<button
|
||||
class="te-btn te-btn--primary"
|
||||
type="button"
|
||||
[disabled]="closeAccountChecked && (!closurePreview || closurePreviewLoading)"
|
||||
(click)="applyCloseAccount()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── REJECT DIALOG (manager review) ───────────────────────────────────── -->
|
||||
@if (showRejectDialog) {
|
||||
<div class="te-confirm-overlay" (click)="cancelRejectReview()">
|
||||
|
||||
@@ -457,6 +457,21 @@ $ws-fld-bd: #c8d8eb;
|
||||
}
|
||||
}
|
||||
|
||||
&--action {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgb(79 70 229 / 30%);
|
||||
|
||||
svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
&--post {
|
||||
background: $ws-accent;
|
||||
color: #fff;
|
||||
@@ -2335,12 +2350,171 @@ select.te-cell {
|
||||
&--disbursement {
|
||||
background: linear-gradient(135deg, #0284c7, #0369a1);
|
||||
}
|
||||
|
||||
&--action {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
}
|
||||
}
|
||||
|
||||
.te-confirm-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
// ── Account Actions popup ─────────────────────────────────────────────────────
|
||||
.te-aa-box {
|
||||
width: 460px;
|
||||
}
|
||||
|
||||
.te-aa-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&__no {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
.te-aa-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1.5px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #dc2626;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11.5px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.te-aa-calc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&--loading,
|
||||
&--error {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #166534;
|
||||
|
||||
&--total {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1.5px dashed #bbf7d0;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #4d7c56;
|
||||
}
|
||||
|
||||
&__warn {
|
||||
margin-top: 4px;
|
||||
padding: 7px 9px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.te-aa-next {
|
||||
margin-top: 4px;
|
||||
padding: 9px 11px;
|
||||
background: #eef2ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #3730a3;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.te-confirm-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
@@ -3107,6 +3281,21 @@ select.te-cell {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px rgb(16 185 129 / 12%);
|
||||
|
||||
/* A closure is destructive, so it must not read like the ordinary green "all good" banner. */
|
||||
&--closure {
|
||||
background: linear-gradient(135deg, #eef2ff, #f5f3ff);
|
||||
border-color: #a5b4fc;
|
||||
box-shadow: 0 1px 4px rgb(79 70 229 / 14%);
|
||||
|
||||
.ws-withdrawal-banner__icon {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.ws-withdrawal-banner__title {
|
||||
color: #3730a3;
|
||||
}
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CashierService } from '../cashier.service';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||
import { ClerkClosureRequestService } from '../clerk-closure-request.service';
|
||||
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
|
||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||
@@ -1455,14 +1456,39 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// Active approved withdrawal (cashier disburse flow)
|
||||
activeWithdrawalRequest: any = null;
|
||||
activeDividendClaimRequest: any = null;
|
||||
activeClosureRequest: any = null;
|
||||
loadingWithdrawal = false;
|
||||
|
||||
// ── Account Actions (clerk) — "Close this account" ──────────────────────────
|
||||
/** Account Actions popup, opened from the footer once an account is loaded. */
|
||||
showAccountActions = false;
|
||||
/** The one action offered today: close the account (Cash payout, maker-checker). */
|
||||
closeAccountChecked = false;
|
||||
/** Interest accrued to today, previewed the moment the checkbox is ticked so the clerk can see
|
||||
* what the member will actually receive before submitting. Null until calculated.
|
||||
* `upToDate` is false when the backend could not refresh the accrual (e.g. this user lacks the
|
||||
* CALCULATEINTEREST permission) and the figure shown is the last stored one instead. */
|
||||
closurePreview: {
|
||||
balance: number;
|
||||
interest: number;
|
||||
total: number;
|
||||
upToDate: boolean;
|
||||
asOfDate: string | null;
|
||||
} | null = null;
|
||||
closurePreviewLoading = false;
|
||||
closurePreviewError = '';
|
||||
/** Closure applied to the slip but not yet submitted: the AMOUNT is filled and Save will raise a
|
||||
* closure request instead of an ordinary withdrawal request. TOK. NO. and DESCRIPTION are taken
|
||||
* from the slip's own fields, not from the popup. */
|
||||
pendingClosure = false;
|
||||
|
||||
private http = inject(HttpClient);
|
||||
private cashierService = inject(CashierService);
|
||||
private authService = inject(AuthenticationService);
|
||||
private dialog = inject(MatDialog);
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||
private closureRequestService = inject(ClerkClosureRequestService);
|
||||
private dividendClaimRequestService = inject(DividendClaimRequestService);
|
||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||
@@ -2535,16 +2561,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.loadingWithdrawal = true;
|
||||
this.activeWithdrawalRequest = null;
|
||||
this.activeDividendClaimRequest = null;
|
||||
this.activeClosureRequest = null;
|
||||
this.clerkWithdrawalService
|
||||
.getByToken(token)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
|
||||
// which are legitimate matches with their own specific error) — try the dividend
|
||||
// cash-claim token pool before giving up, since both share the same TOK. NO. field.
|
||||
// cash-claim pool, then the account-closure pool, before giving up: all three share
|
||||
// the same TOK. NO. field at this counter.
|
||||
if (err?.status === 404) {
|
||||
return this.dividendClaimRequestService.getByToken(token).pipe(
|
||||
catchError((divErr: any) => {
|
||||
if (divErr?.status === 404) {
|
||||
return this.closureRequestService.getByToken(token).pipe(
|
||||
catchError((closeErr: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
closeErr?.error?.message ??
|
||||
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
`Token '${token}' could not be verified. Please try again.`;
|
||||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||||
return of(null);
|
||||
}),
|
||||
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
|
||||
);
|
||||
}
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
divErr?.error?.message ??
|
||||
@@ -2553,7 +2595,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||||
return of(null);
|
||||
}),
|
||||
map((divReq: any) => (divReq ? { __dividendClaim: true, ...divReq } : null))
|
||||
// Only tag a result that actually came from the dividend pool — a closure hit
|
||||
// recovered by the inner fallback above is already tagged and must stay that way.
|
||||
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
|
||||
);
|
||||
}
|
||||
this.loadingWithdrawal = false;
|
||||
@@ -2586,6 +2630,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.__closureRequest) {
|
||||
this.activeClosureRequest = req;
|
||||
// The account is already CLOSED at Fineract level by the time a cashier sees this token
|
||||
// (the close command ran at manager approval), so its own detail response would show a
|
||||
// closed account with a zero balance. What the cashier needs here is the rounded cash
|
||||
// figure to count out, which the request itself carries.
|
||||
this.accountDetails = {
|
||||
id: null,
|
||||
accountNo: req.account_no,
|
||||
clientName: req.client_name
|
||||
};
|
||||
this.kycDetails = null;
|
||||
this.clientDetails = null;
|
||||
this.accountError = '';
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = 'SD';
|
||||
row.amount = Number(req.cashier_disbursed_amount) || null;
|
||||
row.dbCr = 'Dr';
|
||||
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
|
||||
// real cash payment type here so the cashier isn't asked to pick one and the
|
||||
// "Payment Type Required" guard on the disbursement path is satisfied.
|
||||
row.paymentTypeId = req.payment_type_id ?? this.findCashPaymentTypeId();
|
||||
row.description = 'Account closure — ' + (req.client_name ?? req.account_no);
|
||||
this.selectedRow = row;
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWithdrawalRequest = req;
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = 'SD';
|
||||
@@ -3626,6 +3697,173 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Account Actions (clerk): close this account ──────────────────────────────
|
||||
|
||||
/** The Action button only makes sense once a real, ACTIVE savings account is on screen — a
|
||||
* closure request needs an actual account id to submit against, and only a clerk is the maker
|
||||
* in this flow (the cashier's side is the token disbursement, not the request). */
|
||||
get canShowAccountActions(): boolean {
|
||||
return (
|
||||
this.isClerk &&
|
||||
!this.reviewMode &&
|
||||
!this.editMode &&
|
||||
this.selectedRow?.acTyp === 'SD' &&
|
||||
!!this.accountDetails?.id &&
|
||||
this.accountDetails?.status?.value === 'Active'
|
||||
);
|
||||
}
|
||||
|
||||
openAccountActions(): void {
|
||||
this.closeAccountChecked = this.pendingClosure;
|
||||
this.closurePreviewError = '';
|
||||
if (!this.pendingClosure) {
|
||||
this.closurePreview = null;
|
||||
}
|
||||
this.showAccountActions = true;
|
||||
}
|
||||
|
||||
closeAccountActions(): void {
|
||||
this.showAccountActions = false;
|
||||
}
|
||||
|
||||
/** Ticking "Close this account" immediately previews what the member would receive — the balance
|
||||
* plus interest accrued to date — so the clerk sees the real figure before submitting. Nothing
|
||||
* is posted; the binding amount is computed by Fineract's own close command at approval time. */
|
||||
onCloseAccountToggle(): void {
|
||||
if (!this.closeAccountChecked) {
|
||||
this.closurePreview = null;
|
||||
this.closurePreviewError = '';
|
||||
return;
|
||||
}
|
||||
const accountId = this.accountDetails?.id;
|
||||
if (!accountId) return;
|
||||
|
||||
this.closurePreviewLoading = true;
|
||||
this.closurePreviewError = '';
|
||||
this.closureRequestService
|
||||
.preview(accountId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.closurePreviewError =
|
||||
err?.error?.message ??
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
'Could not calculate the closing amount. Please try again.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.closurePreviewLoading = false;
|
||||
if (!res) return;
|
||||
this.closurePreview = {
|
||||
balance: Number(res.balance ?? 0),
|
||||
interest: Number(res.accruedInterest ?? 0),
|
||||
total: Number(res.total ?? 0),
|
||||
upToDate: res.interestUpToDate !== false,
|
||||
asOfDate: res.asOfDate ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the closure to the slip on screen — it does NOT submit anything. The calculated payout
|
||||
* drops into the row's AMOUNT, the row is marked as a closure, and the clerk then fills TOK. NO.
|
||||
* and DESCRIPTION on the slip itself and presses Save like any other entry. Save is what actually
|
||||
* raises the request (see submitClosureRequest()).
|
||||
*/
|
||||
applyCloseAccount(): void {
|
||||
if (!this.closeAccountChecked) {
|
||||
// Unticking is how the clerk backs out of a closure they had applied.
|
||||
this.clearPendingClosure();
|
||||
this.showAccountActions = false;
|
||||
return;
|
||||
}
|
||||
if (!this.closurePreview) return;
|
||||
|
||||
// Fall back to the first row: the popup is opened from the footer, so a stray click can leave
|
||||
// selectedRow unset even though a row is plainly on screen — and Save reads selectedRow too.
|
||||
const row = this.selectedRow ?? this.rows[0] ?? null;
|
||||
if (!row) {
|
||||
this.showAlert('error', 'No Transaction Row', ['Add a transaction row before closing the account.']);
|
||||
return;
|
||||
}
|
||||
this.selectedRow = row;
|
||||
row.amount = this.closurePreview.total;
|
||||
row.dbCr = 'Dr';
|
||||
if (!row.description) {
|
||||
row.description = 'Account closure — full balance withdrawal';
|
||||
}
|
||||
this.pendingClosure = true;
|
||||
this.showAccountActions = false;
|
||||
}
|
||||
|
||||
/** Drops closure mode and the amount it filled in, so the slip goes back to an ordinary entry. */
|
||||
clearPendingClosure(): void {
|
||||
this.pendingClosure = false;
|
||||
this.closeAccountChecked = false;
|
||||
this.closurePreview = null;
|
||||
this.closurePreviewError = '';
|
||||
const row = this.selectedRow;
|
||||
if (row && row.description === 'Account closure — full balance withdrawal') {
|
||||
row.description = '';
|
||||
}
|
||||
if (row) {
|
||||
row.amount = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the closure request from the slip's own fields. Reached from confirmSubmit() when the
|
||||
* clerk presses Save with closure mode applied, instead of the ordinary withdrawal-request path.
|
||||
*/
|
||||
private submitClosureRequest(row: TxnRow): void {
|
||||
const accountId = this.accountDetails?.id;
|
||||
if (!accountId) return;
|
||||
|
||||
this.saving = true;
|
||||
this.closureRequestService
|
||||
.submit({
|
||||
savingsAccountId: accountId,
|
||||
paymentTypeId: null as number | null,
|
||||
paymentTypeName: 'Cash',
|
||||
description: row.description || null,
|
||||
tokNo: row.tokNo?.trim() || null
|
||||
})
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.message ??
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
'Could not submit the closure request. Please try again.';
|
||||
this.showAlert('error', 'Closure Request Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (!res) return;
|
||||
|
||||
const preview = this.closurePreview;
|
||||
this.showAlert(
|
||||
'success',
|
||||
'Closure Request Submitted',
|
||||
[
|
||||
`Account ${this.accountDetails?.accountNo} has been sent to the Bank Manager for closure approval.`,
|
||||
preview
|
||||
? `Payout: ₹${preview.total.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (balance ₹${preview.balance.toLocaleString('en-IN', { minimumFractionDigits: 2 })} + interest ₹${preview.interest.toLocaleString('en-IN', { minimumFractionDigits: 2 })}).`
|
||||
: '',
|
||||
`Token No: ${row.tokNo?.trim()} — the cashier will hand over the cash against this token once approved.`,
|
||||
'Cash is handed over rounded to whole rupees; the paise difference goes to the Round Off Account.'
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
this.pendingClosure = false;
|
||||
this.closeAccountChecked = false;
|
||||
this.closurePreview = null;
|
||||
this.onCancel();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ──
|
||||
|
||||
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
|
||||
@@ -3873,20 +4111,37 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const row = this.selectedRow;
|
||||
if (!row || !row.acNo.trim() || !this.accountDetails) return;
|
||||
// Never fail silently here: a Save that does literally nothing is indistinguishable from a
|
||||
// broken button, and this guard is the one path in onSave() that used to return without saying
|
||||
// anything.
|
||||
if (!row || !row.acNo.trim() || !this.accountDetails) {
|
||||
this.showAlert('error', 'Nothing to Save', [
|
||||
!row
|
||||
? 'No transaction row is selected. Click into a row first.'
|
||||
: !row.acNo.trim()
|
||||
? 'Enter an account number on the row.'
|
||||
: 'Account details are not loaded yet. Re-enter the account number and wait for it to load.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||
const isDdCashGl = this.isDdCashGlRow(row);
|
||||
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
||||
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
|
||||
// A closure's amount is the calculated payout on the row (balance + interest to date), never the
|
||||
// counted denomination: the clerk raising the request counts no cash — the cashier does that
|
||||
// later, at hand-over. Keyed off pendingClosure rather than clerkMode because clerkMode is only
|
||||
// set by the Clerk Dashboard wrapper, so a clerk reaching this screen by any other route would
|
||||
// otherwise have the payout silently replaced by a zero denomination total.
|
||||
const amount = isDdCashGl
|
||||
? this.ddTotalPayableFor(row)
|
||||
: isCash && !this.clerkMode
|
||||
: isCash && !this.clerkMode && !this.pendingClosure
|
||||
? this.denominationTotal
|
||||
: (row.amount ?? this.denominationTotal);
|
||||
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl && !this.pendingClosure) {
|
||||
this.showAlert('error', 'Denomination Required', [
|
||||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||||
]);
|
||||
@@ -3953,7 +4208,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
!this.reviewMode &&
|
||||
!isDeposit &&
|
||||
!this.activeWithdrawalRequest &&
|
||||
!this.activeDividendClaimRequest
|
||||
!this.activeDividendClaimRequest &&
|
||||
!this.activeClosureRequest
|
||||
) {
|
||||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||
'Withdrawals cannot be processed directly by the cashier.',
|
||||
@@ -3963,9 +4219,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved withdrawal / dividend cash claim: amount/denomination must exactly match
|
||||
if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
|
||||
const rawApprovedAmt = Number(this.activeWithdrawalRequest?.amount ?? this.activeDividendClaimRequest?.amount);
|
||||
// Approved withdrawal / dividend cash claim / account closure: amount/denomination must match
|
||||
if (
|
||||
!this.isClerk &&
|
||||
(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) &&
|
||||
!isDeposit
|
||||
) {
|
||||
const rawApprovedAmt = Number(
|
||||
this.activeWithdrawalRequest?.amount ??
|
||||
this.activeDividendClaimRequest?.amount ??
|
||||
this.activeClosureRequest?.cashier_disbursed_amount
|
||||
);
|
||||
// A dividend cash claim's liability is stored with paise precision (a proportional profit
|
||||
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
|
||||
// the floored amount so an exact denomination match is actually achievable. Ordinary
|
||||
@@ -4068,7 +4332,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// Main Vault) already posted at claim-approval time — there is no real savings/current
|
||||
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
|
||||
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
|
||||
if (this.activeDividendClaimRequest) {
|
||||
// An approved account closure is the same situation: close() already posted the interest and the
|
||||
// real withdrawal at manager approval, and the account is CLOSED by the time a cashier scans the
|
||||
// token — so accountDetails here is a stub with no balance and the balance/min-balance/facility
|
||||
// checks below would reject the hand-over with "Withdrawable Amount: ₹0.00". Only the cash-drawer
|
||||
// checks still matter, and they are the ones kept.
|
||||
if (this.activeDividendClaimRequest || this.activeClosureRequest) {
|
||||
if (!row.paymentTypeId) {
|
||||
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
|
||||
return;
|
||||
@@ -4088,6 +4357,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clerk raising a closure request: nothing moves yet, so the ordinary withdrawal gauntlet below
|
||||
// does not apply and actively rejects a valid closure —
|
||||
// · Balance: the payout is balance + interest accrued but NOT yet posted, so it is legitimately
|
||||
// MORE than today's withdrawable amount. close() posts that interest before withdrawing.
|
||||
// · Min balance: meaningless for an account that is being closed to zero.
|
||||
// · Withdrawal facility: a member closing their account is not making an ordinary withdrawal.
|
||||
// · Denomination: counted by the cashier at hand-over, not by the clerk here.
|
||||
// Everything that genuinely matters (token number, account loaded, ACTIVE status) is already
|
||||
// checked in onSave() before this point, and the backend re-validates on submit.
|
||||
if (this.pendingClosure && this.isClerk && isWithdrawal) {
|
||||
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
|
||||
return;
|
||||
}
|
||||
|
||||
// GL transactions post as journal entries — skip account-level facility/balance checks
|
||||
if (isGlType) {
|
||||
if (!this.cashVaultGlId) {
|
||||
@@ -4153,7 +4436,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// and posted the real withdrawal (money already left the account). Re-running the balance/
|
||||
// min-balance checks here would compare the post-deduction balance against the same
|
||||
// already-approved amount again, which fails for almost any real withdrawal.
|
||||
const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
|
||||
const isTokenDisbursement = !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeClosureRequest);
|
||||
|
||||
if (!isTokenDisbursement) {
|
||||
// 3. Balance check
|
||||
@@ -4209,7 +4492,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||||
const isDisbursement =
|
||||
!isDeposit && !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest);
|
||||
!isDeposit &&
|
||||
!this.isClerk &&
|
||||
(!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest || !!this.activeClosureRequest);
|
||||
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
|
||||
const isDepositRequest =
|
||||
isDeposit &&
|
||||
@@ -4229,7 +4514,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
? 'DEPOSIT REQUEST'
|
||||
: isDeposit
|
||||
? 'DEPOSIT'
|
||||
: 'WITHDRAWAL',
|
||||
: this.pendingClosure && this.isClerk
|
||||
? 'ACCOUNT CLOSURE'
|
||||
: 'WITHDRAWAL',
|
||||
isDisbursement,
|
||||
isDepositRequest,
|
||||
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
|
||||
@@ -4240,7 +4527,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
paymentType: selectedPt?.name ?? 'Cash',
|
||||
chequeNo: row.chequeNo || null,
|
||||
symbol: this.currencySymbol,
|
||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
|
||||
tokNo:
|
||||
this.activeWithdrawalRequest?.tok_no ??
|
||||
this.activeDividendClaimRequest?.tok_no ??
|
||||
this.activeClosureRequest?.tok_no ??
|
||||
null,
|
||||
isDdCashGl: this.isDdCashGlRow(row),
|
||||
ddFaceValue: row.amount ?? 0,
|
||||
ddCharges: row.ddCharges ?? 0,
|
||||
@@ -4284,6 +4575,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clerk applied "Close this account" from Action → raise a closure request rather than an
|
||||
// ordinary withdrawal request. Must come first: a closure IS a full-balance withdrawal, so the
|
||||
// plain clerk-withdrawal branch below would otherwise swallow it and the account would never
|
||||
// actually close.
|
||||
if (this.pendingClosure && this.isClerk && !isDeposit) {
|
||||
this.submitClosureRequest(row);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
|
||||
if (this.clerkMode && !isDeposit) {
|
||||
this.submitClerkWithdrawalRequest(row, amount);
|
||||
@@ -4339,6 +4639,46 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved account closure token → the close command (interest, TDS and the real GL
|
||||
// withdrawal) already ran at manager approval. Nothing new is posted here either — this only
|
||||
// records the physical hand-over of the rounded cash figure, mirroring the two branches above.
|
||||
if (!this.isClerk && this.activeClosureRequest && !isDeposit) {
|
||||
this.saving = true;
|
||||
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const denomJson =
|
||||
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
|
||||
const req = this.activeClosureRequest;
|
||||
this.closureRequestService
|
||||
.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) {
|
||||
const closureVoucher = res.voucherNo || req.voucher_no || '—';
|
||||
this.showAlert('success', 'Account Closed & Cash Disbursed', [
|
||||
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} handed over to the customer.`,
|
||||
`Account ${req.account_no} is now CLOSED.`,
|
||||
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
|
||||
`Voucher No: ${closureVoucher}`
|
||||
]);
|
||||
this.activeClosureRequest = null;
|
||||
this.clearDenominations();
|
||||
this.loadOpeningPosition();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
|
||||
// Extra safety net in case confirmSubmit() is ever called directly
|
||||
if (
|
||||
@@ -4346,7 +4686,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
!this.reviewMode &&
|
||||
!isDeposit &&
|
||||
!this.activeWithdrawalRequest &&
|
||||
!this.activeDividendClaimRequest
|
||||
!this.activeDividendClaimRequest &&
|
||||
!this.activeClosureRequest
|
||||
) {
|
||||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||||
'Withdrawals must go through the maker-checker approval process.',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<!--
|
||||
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 class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CLERK · CLOSURE</div>
|
||||
<h1 class="pg-title">My Closure Requests</h1>
|
||||
<p class="pg-sub">
|
||||
Account closure requests you have submitted, and their approval / cashier status. To close an account, use the
|
||||
"Close this account" action on the account's own Transaction Entry screen.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th>Payout Mode</th>
|
||||
<th>Token No</th>
|
||||
<th class="num">Closing Amount</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No closure requests found.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (r of requests; track r.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.account_no }}</span>
|
||||
</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td>{{ r.payment_type_name || 'Cash' }}</td>
|
||||
<td>{{ r.tok_no || '—' }}</td>
|
||||
<td class="num">
|
||||
@if (r.closing_amount != null) {
|
||||
₹{{ r.closing_amount | number: '1.2-2' }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 { RouterModule } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkClosureRequestService } from '../../cashier/clerk-closure-request.service';
|
||||
|
||||
/** Clerk Dashboard "My Closure Requests" — lists the Cash account-closure requests this clerk has
|
||||
* submitted (from the account's own Transaction Entry screen) and their approval/disbursement
|
||||
* status. Mirrors MyShareWithdrawalRequestsComponent. There is no "New Closure Request" button
|
||||
* here -- submission happens in-context on the account's own screen, not from this list. */
|
||||
@Component({
|
||||
selector: 'mifosx-my-closure-requests',
|
||||
templateUrl: './my-closure-requests.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class MyClosureRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkClosureRequestService);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved — awaiting cashier' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
{ value: 'CLOSED', label: 'Closed' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'inv-badge--active',
|
||||
APPROVED: 'inv-badge--matured',
|
||||
REJECTED: 'inv-badge--written_off',
|
||||
CLOSED: 'inv-badge--matured'
|
||||
};
|
||||
return `inv-badge ${map[status] ?? ''}`;
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Awaiting Cashier',
|
||||
REJECTED: 'Rejected',
|
||||
CLOSED: 'Closed'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,17 @@
|
||||
</svg>
|
||||
My Share Requests
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/closure-requests')"
|
||||
[routerLink]="['/clerk/closure-requests']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
My Closure Requests
|
||||
</a>
|
||||
@if (clerkAccountLoaded) {
|
||||
<div class="cashier-nav__item-wrap">
|
||||
<a
|
||||
@@ -289,11 +300,7 @@
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/voucher']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<a class="cashier-nav__item" [routerLink]="['/cashier/voucher']" routerLinkActive="cashier-nav__item--active">
|
||||
<fa-icon icon="receipt"></fa-icon> Voucher
|
||||
</a>
|
||||
<a
|
||||
|
||||
@@ -278,6 +278,13 @@ export class SavingsAccountViewComponent implements OnInit {
|
||||
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||
);
|
||||
}
|
||||
// Closing an account is not a one-click action from here: it pays real cash out of a teller's
|
||||
// drawer, so it goes through the Clerk -> Manager -> Cashier maker-checker flow instead. The
|
||||
// clerk starts it with "Action > Close this account" on the account's own Transaction Entry
|
||||
// screen (see TransactionEntryComponent#submitCloseAccount), which also shows the interest
|
||||
// accrued to date before anything is submitted.
|
||||
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Close');
|
||||
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Close');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
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">lock</mat-icon>
|
||||
<div>
|
||||
<div class="wri-action-bar__title">Closure Requests</div>
|
||||
<div class="wri-action-bar__sub">Review, approve or reject savings account closure 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>
|
||||
|
||||
<input
|
||||
class="wri-search-input"
|
||||
type="text"
|
||||
placeholder="Search account no, client, token…"
|
||||
[(ngModel)]="searchTerm"
|
||||
(keyup.enter)="onSearchChange()"
|
||||
/>
|
||||
|
||||
<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 closure requests</p>
|
||||
<p class="empty-text">
|
||||
@if (filterStatus === 'PENDING') {
|
||||
There are no pending closure requests waiting for review.
|
||||
} @else {
|
||||
No {{ filterStatus | lowercase }} closure requests found.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wri-table-wrap">
|
||||
<table class="wri-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th>Payout Mode</th>
|
||||
<th>Token No</th>
|
||||
<th class="num">Closing Amount</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" (click)="review(r)">
|
||||
<td class="cell-mono">{{ r.account_no }}</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td>
|
||||
<span class="wri-credit-badge">{{ r.payment_type_name || 'Cash' }}</span>
|
||||
</td>
|
||||
<td class="cell-mono">{{ r.tok_no || '—' }}</td>
|
||||
<td class="num wri-amount">
|
||||
@if (r.closing_amount != null) {
|
||||
₹{{ r.closing_amount | number: '1.2-2' }}
|
||||
} @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>
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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-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-search-input {
|
||||
height: 34px;
|
||||
min-width: 240px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-left: auto;
|
||||
|
||||
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;
|
||||
margin-left: 0;
|
||||
|
||||
&: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-action {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wri-action-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wri-credit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wri-none {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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 { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkClosureRequestService } from '../../../cashier/clerk-closure-request.service';
|
||||
import { ClosureReviewDialogComponent } from './closure-review-dialog/closure-review-dialog.component';
|
||||
|
||||
/** Approval Center — Closure Requests tab. A Bank Manager (checker) reviews, approves or rejects
|
||||
* Cash savings account closure requests submitted from the Clerk Dashboard. "Review" opens
|
||||
* ClosureReviewDialogComponent as a MatDialog popup (mirrors DisbursementRequestsComponent) --
|
||||
* no separate route navigation. */
|
||||
@Component({
|
||||
selector: 'mifosx-closure-requests',
|
||||
templateUrl: './closure-requests.component.html',
|
||||
styleUrls: ['./closure-requests.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class ClosureRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkClosureRequestService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
searchTerm = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved — awaiting cashier' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
{ value: 'CLOSED', label: 'Closed' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getPendingRequests(
|
||||
this.filterStatus,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize,
|
||||
undefined,
|
||||
undefined,
|
||||
this.searchTerm.trim() || undefined
|
||||
)
|
||||
.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();
|
||||
}
|
||||
|
||||
onSearchChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
review(req: any): void {
|
||||
this.dialog
|
||||
.open(ClosureReviewDialogComponent, {
|
||||
data: { requestId: req.id },
|
||||
autoFocus: false
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(() => this.load());
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'wri-status--pending',
|
||||
APPROVED: 'wri-status--approved',
|
||||
REJECTED: 'wri-status--rejected',
|
||||
CLOSED: 'wri-status--closed'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
APPROVED: 'Awaiting Cashier',
|
||||
REJECTED: 'Rejected',
|
||||
CLOSED: 'Closed'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<!--
|
||||
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="crv">
|
||||
<!-- ── HEADER ── -->
|
||||
<nav class="cashier-nav cashier-nav--clerk">
|
||||
<span class="crv-header-title">Closure Review</span>
|
||||
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
|
||||
</nav>
|
||||
|
||||
<!-- ── LOADING ── -->
|
||||
@if (loading) {
|
||||
<div class="crv-loading">
|
||||
<mat-icon class="crv-loading__icon">hourglass_empty</mat-icon>
|
||||
<span>Loading request…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── NOT FOUND ── -->
|
||||
@if (!loading && !request) {
|
||||
<div class="crv-loading">
|
||||
<mat-icon class="crv-loading__icon">error_outline</mat-icon>
|
||||
<span>Request not found.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── MAIN CONTENT ── -->
|
||||
@if (!loading && request) {
|
||||
<div class="crv-body">
|
||||
<!-- Header card -->
|
||||
<div class="crv-card crv-card--header">
|
||||
<div class="crv-card-header">
|
||||
<div class="crv-card-header__left">
|
||||
<span class="crv-account cell-mono">{{ request.account_no }}</span>
|
||||
<span class="crv-status" [ngClass]="statusClass(request.status)">{{ request.status }}</span>
|
||||
</div>
|
||||
<div class="crv-card-header__meta">
|
||||
<span class="crv-meta-item">
|
||||
<mat-icon>person</mat-icon>
|
||||
{{ request.clerk_name }} ({{ request.clerk_username }})
|
||||
</span>
|
||||
<span class="crv-meta-item">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ formatDateTime(request.submitted_at) }}
|
||||
</span>
|
||||
@if (request.actioned_at) {
|
||||
<span class="crv-meta-item">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Actioned: {{ formatDateTime(request.actioned_at) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closure details -->
|
||||
<div class="crv-card">
|
||||
<div class="crv-section-title">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
Closure Details
|
||||
</div>
|
||||
<div class="crv-detail-grid">
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Client</span>
|
||||
<span class="crv-detail-value">{{ request.client_name }}</span>
|
||||
</div>
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Payout Mode</span>
|
||||
<span class="crv-detail-value">{{ request.payment_type_name || 'Cash' }}</span>
|
||||
</div>
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Token No</span>
|
||||
<span class="crv-detail-value cell-mono">{{ request.tok_no || '—' }}</span>
|
||||
</div>
|
||||
@if (request.description) {
|
||||
<div class="crv-detail-item crv-detail-item--full">
|
||||
<span class="crv-detail-label">Reason / Narration</span>
|
||||
<span class="crv-detail-value">{{ request.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Computed amounts (once approved) -->
|
||||
@if (request.closing_amount != null) {
|
||||
<div class="crv-card crv-card--approved">
|
||||
<div class="crv-section-title">
|
||||
<mat-icon>calculate</mat-icon>
|
||||
Computed at Approval
|
||||
</div>
|
||||
<div class="crv-detail-grid">
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Exact Closing Amount</span>
|
||||
<span class="crv-detail-value crv-amount">₹{{ request.closing_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (request.rounding_amount && request.rounding_amount !== 0) {
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Rounding ({{ request.rounding_amount > 0 ? 'up' : 'down' }})</span>
|
||||
<span class="crv-detail-value"
|
||||
>{{ request.rounding_amount > 0 ? '+' : '' }}₹{{ request.rounding_amount | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Cash to Hand Over</span>
|
||||
<span class="crv-detail-value crv-amount">₹{{ request.cashier_disbursed_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (request.voucher_no) {
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Voucher No</span>
|
||||
<span class="crv-detail-value cell-mono">{{ request.voucher_no }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Rejection reason (if rejected) -->
|
||||
@if (request.status === 'REJECTED' && request.rejection_reason) {
|
||||
<div class="crv-card crv-card--rejected">
|
||||
<div class="crv-section-title">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Rejection Reason
|
||||
</div>
|
||||
<p class="crv-rejection-text">{{ request.rejection_reason }}</p>
|
||||
<div class="crv-meta-item" style="margin: 0 22px 14px">
|
||||
<mat-icon>manage_accounts</mat-icon>
|
||||
{{ request.manager_name || request.manager_username || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action buttons (only for PENDING) -->
|
||||
@if (request.status === 'PENDING') {
|
||||
<div class="crv-actions">
|
||||
@if (!showRejectForm) {
|
||||
<button type="button" class="crv-btn crv-btn--approve" [disabled]="acting" (click)="approve()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ acting ? 'Processing…' : 'Approve Closure' }}
|
||||
</button>
|
||||
<button type="button" class="crv-btn crv-btn--reject" [disabled]="acting" (click)="showRejectForm = true">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
} @else {
|
||||
<div class="crv-reject-form">
|
||||
<label class="crv-reject-label">Rejection Reason</label>
|
||||
<textarea
|
||||
class="crv-reject-textarea"
|
||||
[(ngModel)]="rejectionReason"
|
||||
placeholder="State the reason for rejection…"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="crv-reject-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="crv-btn crv-btn--reject"
|
||||
[disabled]="acting || !rejectionReason.trim()"
|
||||
(click)="reject()"
|
||||
>
|
||||
<mat-icon>cancel</mat-icon>
|
||||
{{ acting ? 'Processing…' : 'Confirm Rejection' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="crv-btn crv-btn--ghost"
|
||||
[disabled]="acting"
|
||||
(click)="showRejectForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$brand: #0f4f3c;
|
||||
$brand-light: #e6f4f0;
|
||||
$success: #16a34a;
|
||||
$success-bg: #f0fdf4;
|
||||
$danger: #dc2626;
|
||||
$danger-bg: #fef2f2;
|
||||
$warning: #d97706;
|
||||
$warning-bg: #fffbeb;
|
||||
$text-dark: #0f172a;
|
||||
$text-mid: #475569;
|
||||
$text-soft: #94a3b8;
|
||||
$border: #e2e8f0;
|
||||
$bg-page: #f1f5f9;
|
||||
$bg-white: #fff;
|
||||
$radius: 12px;
|
||||
$shadow-sm: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
$shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
width: min(680px, 92vw);
|
||||
border-radius: 10px;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
.cashier-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: $bg-white;
|
||||
border-bottom: 1px solid $border;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&__esc-hint {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: $text-soft;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: $bg-page;
|
||||
color: $text-mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crv-header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-mid;
|
||||
}
|
||||
|
||||
.crv-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
font-size: 15px;
|
||||
color: $text-soft;
|
||||
|
||||
&__icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: $text-soft;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-body {
|
||||
padding: 20px 20px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.crv-card {
|
||||
background: $bg-white;
|
||||
border-radius: $radius;
|
||||
border: 1px solid $border;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
|
||||
&--header {
|
||||
border-left: 4px solid $brand;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
border-left: 4px solid $success;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
border-left: 4px solid $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-card-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
color: $text-mid;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-account {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $brand;
|
||||
letter-spacing: 0.04em;
|
||||
background: $brand-light;
|
||||
padding: 4px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.crv-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--pending {
|
||||
background: $warning-bg;
|
||||
color: $warning;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: $success-bg;
|
||||
color: $success;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
color: $text-mid;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: $text-soft;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: $text-mid;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
border-bottom: 1px solid $border;
|
||||
background: linear-gradient(90deg, #f8fafc 0%, $bg-white 100%);
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $brand;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
padding: 16px 20px;
|
||||
gap: 14px 24px;
|
||||
}
|
||||
|
||||
.crv-detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-detail-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: $text-soft;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.crv-detail-value {
|
||||
font-size: 14px;
|
||||
color: $text-dark;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.crv-amount {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
color: $success;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.crv-rejection-text {
|
||||
padding: 12px 20px;
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: $danger;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.crv-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.crv-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
padding: 11px 22px;
|
||||
letter-spacing: 0.01em;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&--approve {
|
||||
background: linear-gradient(135deg, $brand 0%, #1a7a5e 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgb(15 79 60 / 35%);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgb(15 79 60 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
border: 1.5px solid #fca5a5;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: #f1f5f9;
|
||||
color: $text-mid;
|
||||
border: 1px solid $border;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
color: $text-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crv-reject-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: $danger-bg;
|
||||
border: 1.5px solid #fca5a5;
|
||||
border-radius: $radius;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.crv-reject-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: $danger;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.crv-reject-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1.5px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
resize: vertical;
|
||||
color: $text-dark;
|
||||
outline: none;
|
||||
background: $bg-white;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: $danger;
|
||||
box-shadow: 0 0 0 3px rgb(220 38 38 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.crv-reject-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkClosureRequestService } from '../../../../cashier/clerk-closure-request.service';
|
||||
|
||||
/** Manager's Approve/Reject review of one Cash closure request — opened as a MatDialog popup from
|
||||
* the Closure Requests tab, mirroring DisbursementReviewComponent's shape. Approving here is what
|
||||
* actually runs Fineract's close command (pro-rata interest + TDS + the real GL withdrawal all
|
||||
* post at this moment); a cashier then hands over the rounded cash figure against the token. */
|
||||
@Component({
|
||||
selector: 'mifosx-closure-review-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatSnackBarModule,
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './closure-review-dialog.component.html',
|
||||
styleUrl: './closure-review-dialog.component.scss'
|
||||
})
|
||||
export class ClosureReviewDialogComponent implements OnInit {
|
||||
private service = inject(ClerkClosureRequestService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialogRef = inject(MatDialogRef<ClosureReviewDialogComponent>);
|
||||
private dialogData = inject<{ requestId: number }>(MAT_DIALOG_DATA);
|
||||
|
||||
requestId: number | null = null;
|
||||
request: any = null;
|
||||
|
||||
loading = false;
|
||||
acting = false;
|
||||
showRejectForm = false;
|
||||
rejectionReason = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.requestId = this.dialogData?.requestId ?? null;
|
||||
if (this.requestId) this.loadRequest();
|
||||
}
|
||||
|
||||
loadRequest(): void {
|
||||
if (!this.requestId) return;
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getById(this.requestId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.request = res;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
approve(): void {
|
||||
if (!this.requestId || this.acting) return;
|
||||
this.acting = true;
|
||||
this.service
|
||||
.approve(this.requestId)
|
||||
.pipe(catchError((err) => of({ error: err?.error?.errors?.[0]?.defaultUserMessage ?? 'Approval failed.' })))
|
||||
.subscribe((res: any) => {
|
||||
this.acting = false;
|
||||
if (res?.error) {
|
||||
this.snackBar.open(res.error, 'Close', { duration: 6000 });
|
||||
} else {
|
||||
// A nil-balance closure has no cash to hand over, so the backend closes it outright
|
||||
// rather than parking it in the cashier's token queue.
|
||||
const msg =
|
||||
res?.status === 'CLOSED'
|
||||
? 'Account closed. Nil balance — no cash hand-over was required.'
|
||||
: 'Closure approved — ₹' +
|
||||
this.formatAmount(res?.cashierDisbursedAmount) +
|
||||
' is ready for the cashier to hand over against the token.';
|
||||
this.snackBar.open(msg, 'Close', { duration: 6000 });
|
||||
this.close(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
if (!this.requestId || this.acting || !this.rejectionReason.trim()) return;
|
||||
this.acting = true;
|
||||
this.service
|
||||
.reject(this.requestId, this.rejectionReason.trim())
|
||||
.pipe(catchError((err) => of({ error: err?.error?.errors?.[0]?.defaultUserMessage ?? 'Rejection failed.' })))
|
||||
.subscribe((res: any) => {
|
||||
this.acting = false;
|
||||
if (res?.error) {
|
||||
this.snackBar.open(res.error, 'Close', { duration: 6000 });
|
||||
} else {
|
||||
this.snackBar.open('Closure request rejected. The account remains Active and untouched.', 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
this.close(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(refreshed = false): void {
|
||||
this.dialogRef.close({ refreshed });
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'crv-status--pending',
|
||||
APPROVED: 'crv-status--approved',
|
||||
REJECTED: 'crv-status--rejected',
|
||||
CLOSED: 'crv-status--closed'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
formatAmount(val: any): string {
|
||||
const n = Number(val ?? 0);
|
||||
return n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,15 @@
|
||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||
<span>Cheque Book Requests</span>
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
routerLink="./closure-requests"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
||||
<span>Closure Requests</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="ac-tab-panel">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DividendRequestsComponent } from './checker-inbox-and-tasks-tabs/divide
|
||||
import { ShareAdditionRequestsComponent } from './checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component';
|
||||
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
|
||||
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
|
||||
import { ClosureRequestsComponent } from './checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component';
|
||||
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
||||
import { LoanApprovalComponent } from './checker-inbox-and-tasks-tabs/loan-approval/loan-approval.component';
|
||||
import { LoanDisbursalComponent } from './checker-inbox-and-tasks-tabs/loan-disbursal/loan-disbursal.component';
|
||||
@@ -201,6 +202,11 @@ const routes: Routes = [
|
||||
path: 'chequebook-requests',
|
||||
component: ChequebookRequestsComponent,
|
||||
data: { title: 'Cheque Book Requests' }
|
||||
},
|
||||
{
|
||||
path: 'closure-requests',
|
||||
component: ClosureRequestsComponent,
|
||||
data: { title: 'Closure Requests' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260830',
|
||||
'hash': '9f95d011'
|
||||
'version': '260901',
|
||||
'hash': '9739ff07'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user