From ee47a1ce688f2855a8260126bdd3cca02d3f31d9 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 1 Sep 2026 15:45:26 +0530 Subject: [PATCH] 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) --- src/app/cashier/cashier-routing.module.ts | 8 + .../cashier/clerk-closure-request.service.ts | 80 ++++ .../transaction-entry.component.html | 189 ++++++++- .../transaction-entry.component.scss | 189 +++++++++ .../transaction-entry.component.ts | 371 ++++++++++++++++- .../my-closure-requests.component.html | 79 ++++ .../my-closure-requests.component.ts | 112 +++++ src/app/core/shell/shell.component.html | 17 +- .../savings-account-view.component.ts | 7 + .../closure-requests.component.html | 119 ++++++ .../closure-requests.component.scss | 285 +++++++++++++ .../closure-requests.component.ts | 150 +++++++ .../closure-review-dialog.component.html | 184 +++++++++ .../closure-review-dialog.component.scss | 385 ++++++++++++++++++ .../closure-review-dialog.component.ts | 146 +++++++ .../checker-inbox-and-tasks.component.html | 9 + src/app/tasks/tasks-routing.module.ts | 6 + src/environments/.env.ts | 4 +- 18 files changed, 2304 insertions(+), 36 deletions(-) create mode 100644 src/app/cashier/clerk-closure-request.service.ts create mode 100644 src/app/clerk/my-closure-requests/my-closure-requests.component.html create mode 100644 src/app/clerk/my-closure-requests/my-closure-requests.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 0c9ce396e..ded74e9be 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -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: () => diff --git a/src/app/cashier/clerk-closure-request.service.ts b/src/app/cashier/clerk-closure-request.service.ts new file mode 100644 index 000000000..2f018b830 --- /dev/null +++ b/src/app/cashier/clerk-closure-request.service.ts @@ -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 { + return this.http.post('/clerk/closure-requests', data); + } + + getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + 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 { + 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 { + 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 { + return this.http.get(`/clerk/closure-requests/preview/${savingsAccountId}`); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/closure-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + 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 { + 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 { + return this.http.post(`/clerk/closure-requests/${id}/disburse`, { + denominationData, + transactionAmount: transactionAmount != null ? String(transactionAmount) : null + }); + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 8b912fca7..434193e8c 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -236,14 +236,14 @@ [title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'" > {{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }} - @if (!isClerk && activeWithdrawalRequest) { - πŸ”’ + @if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) { + πŸ”’ }
@if (row.acTyp !== 'SHR') { β‚Ή @@ -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)" />
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { @@ -311,8 +311,8 @@ } + @if (isClerk && pendingClosure) { +
+ lock +
+
Closing This Account
+
+ @if (closurePreview) { + Balance: β‚Ή{{ closurePreview.balance | number: '1.2-2' }} + Β· + Interest: +β‚Ή{{ closurePreview.interest | number: '1.2-2' }} + Β· + Payable: β‚Ή{{ closurePreview.total | number: '1.2-2' }} + } +
+
+ Enter TOK. NO. below and press Save to send this for the Bank Manager's + approval. The account closes when the cashier hands over the cash. +
+
+ +
+ } + @if (!isClerk && activeClosureRequest) { +
+ lock +
+
Approved Account Closure Linked
+
+ Token: {{ activeClosureRequest.tok_no }} + Β· + Cash to Hand Over: + β‚Ή{{ activeClosureRequest.cashier_disbursed_amount | number: '1.2-2' }} + Β· + Client: {{ activeClosureRequest.client_name }} + Β· + By Clerk: {{ activeClosureRequest.clerk_name }} + Β· + Approved by: {{ activeClosureRequest.manager_name || 'β€”' }} +
+
+ @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. + } +
+
+ +
+ } @if (loadingAccount) { @@ -1443,14 +1510,16 @@
@if (row.acTyp !== 'SHR') { β‚Ή @@ -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) + " />
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { @@ -1504,8 +1575,10 @@ @@ -1673,6 +1746,19 @@ My Disbursements } + @if (canShowAccountActions) { + + }
+ + +} + @if (showRejectDialog) {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 00c01b31c..bc31277d3 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -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; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index ae589914d..577c9805d 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -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.', diff --git a/src/app/clerk/my-closure-requests/my-closure-requests.component.html b/src/app/clerk/my-closure-requests/my-closure-requests.component.html new file mode 100644 index 000000000..e24e2b230 --- /dev/null +++ b/src/app/clerk/my-closure-requests/my-closure-requests.component.html @@ -0,0 +1,79 @@ + + +
+
+
+
CLERK Β· CLOSURE
+

My Closure Requests

+

+ 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. +

+
+
+ +
+
+ +
+ + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + } + } + +
Account NoClientPayout ModeToken NoClosing AmountSubmitted AtStatus
Loading…
No closure requests found.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.payment_type_name || 'Cash' }}{{ r.tok_no || 'β€”' }} + @if (r.closing_amount != null) { + β‚Ή{{ r.closing_amount | number: '1.2-2' }} + } @else { + β€” + } + {{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} +
+
+
diff --git a/src/app/clerk/my-closure-requests/my-closure-requests.component.ts b/src/app/clerk/my-closure-requests/my-closure-requests.component.ts new file mode 100644 index 000000000..6f7693fe1 --- /dev/null +++ b/src/app/clerk/my-closure-requests/my-closure-requests.component.ts @@ -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 = { + 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 = { + 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); + } + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 29be1c12a..76cc5daa2 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -112,6 +112,17 @@ My Share Requests + + + + + + My Closure Requests + @if (clerkAccountLoaded) {
Exceptions - + Voucher 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'); } /** diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.html new file mode 100644 index 000000000..2be6c7109 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.html @@ -0,0 +1,119 @@ + + +
+ +
+
+ lock +
+
Closure Requests
+
Review, approve or reject savings account closure requests.
+
+
+
+ + +
+ + + + + + + +
+ + + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && requests.length === 0) { +
+ +

No closure requests

+

+ @if (filterStatus === 'PENDING') { + There are no pending closure requests waiting for review. + } @else { + No {{ filterStatus | lowercase }} closure requests found. + } +

+
+ } @else { +
+ + + + + + + + + + + + + + + @for (r of requests; track r.id) { + + + + + + + + + + + } + +
Account NoClientPayout ModeToken NoClosing AmountSubmitted AtStatusAction
{{ r.account_no }}{{ r.client_name }} + {{ r.payment_type_name || 'Cash' }} + {{ r.tok_no || 'β€”' }} + @if (r.closing_amount != null) { + β‚Ή{{ r.closing_amount | number: '1.2-2' }} + } @else { + β€” + } + {{ formatDateTime(r.submitted_at) }} + + {{ statusLabel(r.status) }} + + + +
+
+ + + } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.scss new file mode 100644 index 000000000..2eac9df0f --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.ts new file mode 100644 index 000000000..49e7dc44b --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component.ts @@ -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 = { + 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 = { + 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); + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.html new file mode 100644 index 000000000..a330b0317 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.html @@ -0,0 +1,184 @@ + + +
+ + + + + @if (loading) { +
+ hourglass_empty + Loading request… +
+ } + + + @if (!loading && !request) { +
+ error_outline + Request not found. +
+ } + + + @if (!loading && request) { +
+ +
+
+
+ + {{ request.status }} +
+
+ + person + {{ request.clerk_name }} ({{ request.clerk_username }}) + + + schedule + {{ formatDateTime(request.submitted_at) }} + + @if (request.actioned_at) { + + check_circle + Actioned: {{ formatDateTime(request.actioned_at) }} + + } +
+
+
+ + +
+
+ account_balance_wallet + Closure Details +
+
+
+ Client + {{ request.client_name }} +
+
+ Payout Mode + {{ request.payment_type_name || 'Cash' }} +
+
+ Token No + {{ request.tok_no || 'β€”' }} +
+ @if (request.description) { +
+ Reason / Narration + {{ request.description }} +
+ } +
+
+ + + @if (request.closing_amount != null) { +
+
+ calculate + Computed at Approval +
+
+
+ Exact Closing Amount + β‚Ή{{ request.closing_amount | number: '1.2-2' }} +
+ @if (request.rounding_amount && request.rounding_amount !== 0) { +
+ Rounding ({{ request.rounding_amount > 0 ? 'up' : 'down' }}) + {{ request.rounding_amount > 0 ? '+' : '' }}β‚Ή{{ request.rounding_amount | number: '1.2-2' }} +
+ } +
+ Cash to Hand Over + β‚Ή{{ request.cashier_disbursed_amount | number: '1.2-2' }} +
+ @if (request.voucher_no) { +
+ Voucher No + {{ request.voucher_no }} +
+ } +
+
+ } + + + @if (request.status === 'REJECTED' && request.rejection_reason) { +
+
+ cancel + Rejection Reason +
+

{{ request.rejection_reason }}

+
+ manage_accounts + {{ request.manager_name || request.manager_username || 'β€”' }} +
+
+ } + + + @if (request.status === 'PENDING') { +
+ @if (!showRejectForm) { + + + } @else { +
+ + +
+ + +
+
+ } +
+ } +
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.scss new file mode 100644 index 000000000..4ad4e4915 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.scss @@ -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; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.ts new file mode 100644 index 000000000..432ea649e --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/closure-requests/closure-review-dialog/closure-review-dialog.component.ts @@ -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); + 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 = { + 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); + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index fdd248f94..23c0cd64e 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -200,6 +200,15 @@ Cheque Book Requests
+ + + Closure Requests +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index aec704748..831233c6f 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -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' } } ] }, diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 938c9c82a..e9ec99eda 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260830', - 'hash': '9f95d011' + 'version': '260901', + 'hash': '9739ff07' }, 'allow_switching_backend_instance': true };