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 };