feat(cashier,savings,tasks): clerk-manager-cashier account closure flow
Replaces the one-click Close action on the savings account page (removed from the Danger Zone menu) with a maker-checker flow, since closing an account pays real cash out of a teller's drawer. Clerk: an Action button on Transaction Entry, shown once an ACTIVE savings account is loaded. Ticking "Close this account" previews the payout -- balance plus interest calculated to today by the same code the closure itself runs -- and Save drops that figure into the slip's AMOUNT. The clerk then fills TOK. NO. on the slip and presses Save, which raises the request. Token and narration deliberately live on the slip, not in the popup, so the closure is entered like any other transaction. Manager: a Closure Requests tab in the Approval Center, reviewed in a popup dialog (matching Disbursement Requests) rather than a separate route. Cashier: the closure token is recognised at the counter alongside withdrawal and dividend tokens, auto-filling the rounded cash figure, locking it, and closing the account once the denomination is counted. Save-path fixes, all of which silently blocked a valid closure: - amount came from the denomination total whenever clerkMode was false -- which it is for any route other than the Clerk Dashboard wrapper -- so the calculated payout was replaced by zero - the balance check rejected the payout because it legitimately exceeds today's withdrawable amount: it includes interest close() posts before withdrawing. Min-balance, withdrawal-facility and clerk-side denomination checks do not apply to a closure either - on the cashier side the account is already CLOSED, so accountDetails is a stub with no balance and the same check reported "Withdrawable 0.00" - the closure branch had to precede the plain clerk-withdrawal branch: a closure is a full-balance withdrawal, so that branch would have swallowed it and the account would never have closed - onSave's one silent return now says what is missing, instead of leaving Save looking like a broken button Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+119
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cv-tab-content wri-tab">
|
||||
<!-- ── TITLE BAR ── -->
|
||||
<div class="wri-action-bar">
|
||||
<div class="wri-action-bar__left">
|
||||
<mat-icon class="wri-action-bar__icon">lock</mat-icon>
|
||||
<div>
|
||||
<div class="wri-action-bar__title">Closure Requests</div>
|
||||
<div class="wri-action-bar__sub">Review, approve or reject savings account closure requests.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FILTER BAR ── -->
|
||||
<div class="wri-filter-bar">
|
||||
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="wri-fb-sep"></span>
|
||||
|
||||
<input
|
||||
class="wri-search-input"
|
||||
type="text"
|
||||
placeholder="Search account no, client, token…"
|
||||
[(ngModel)]="searchTerm"
|
||||
(keyup.enter)="onSearchChange()"
|
||||
/>
|
||||
|
||||
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── TABLE ── -->
|
||||
@if (loading) {
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-inbox empty-icon"></i>
|
||||
<p class="empty-title">No closure requests</p>
|
||||
<p class="empty-text">
|
||||
@if (filterStatus === 'PENDING') {
|
||||
There are no pending closure requests waiting for review.
|
||||
} @else {
|
||||
No {{ filterStatus | lowercase }} closure requests found.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wri-table-wrap">
|
||||
<table class="wri-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th>Payout Mode</th>
|
||||
<th>Token No</th>
|
||||
<th class="num">Closing Amount</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
<th class="wri-th-action">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of requests; track r.id) {
|
||||
<tr class="wri-row" (click)="review(r)">
|
||||
<td class="cell-mono">{{ r.account_no }}</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td>
|
||||
<span class="wri-credit-badge">{{ r.payment_type_name || 'Cash' }}</span>
|
||||
</td>
|
||||
<td class="cell-mono">{{ r.tok_no || '—' }}</td>
|
||||
<td class="num wri-amount">
|
||||
@if (r.closing_amount != null) {
|
||||
₹{{ r.closing_amount | number: '1.2-2' }}
|
||||
} @else {
|
||||
<span class="wri-none">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="wri-dt">{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span class="wri-status" [ngClass]="statusClass(r.status)">
|
||||
{{ statusLabel(r.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="wri-action-cell" (click)="$event.stopPropagation()">
|
||||
<button type="button" class="wri-btn wri-btn--review" (click)="review(r)">
|
||||
<mat-icon>rate_review</mat-icon>
|
||||
Review
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wri-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wri-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wri-fb-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.wri-filter-select {
|
||||
height: 34px;
|
||||
min-width: 130px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-search-input {
|
||||
height: 34px;
|
||||
min-width: 240px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
margin-left: auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--refresh {
|
||||
padding: 0 8px;
|
||||
color: #64748b;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
&--review {
|
||||
background: #0f4f3c;
|
||||
color: #fff;
|
||||
border-color: #0f4f3c;
|
||||
margin-left: 0;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wri-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wri-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover td {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-th-action {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wri-action-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wri-amount {
|
||||
font-weight: 700;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
.wri-dt {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wri-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wri-credit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wri-none {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: #94a3b8;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 40px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkClosureRequestService } from '../../../cashier/clerk-closure-request.service';
|
||||
import { ClosureReviewDialogComponent } from './closure-review-dialog/closure-review-dialog.component';
|
||||
|
||||
/** Approval Center — Closure Requests tab. A Bank Manager (checker) reviews, approves or rejects
|
||||
* Cash savings account closure requests submitted from the Clerk Dashboard. "Review" opens
|
||||
* ClosureReviewDialogComponent as a MatDialog popup (mirrors DisbursementRequestsComponent) --
|
||||
* no separate route navigation. */
|
||||
@Component({
|
||||
selector: 'mifosx-closure-requests',
|
||||
templateUrl: './closure-requests.component.html',
|
||||
styleUrls: ['./closure-requests.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class ClosureRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkClosureRequestService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
searchTerm = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved — awaiting cashier' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
{ value: 'CLOSED', label: 'Closed' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getPendingRequests(
|
||||
this.filterStatus,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize,
|
||||
undefined,
|
||||
undefined,
|
||||
this.searchTerm.trim() || undefined
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onSearchChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
review(req: any): void {
|
||||
this.dialog
|
||||
.open(ClosureReviewDialogComponent, {
|
||||
data: { requestId: req.id },
|
||||
autoFocus: false
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(() => this.load());
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'wri-status--pending',
|
||||
APPROVED: 'wri-status--approved',
|
||||
REJECTED: 'wri-status--rejected',
|
||||
CLOSED: 'wri-status--closed'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
APPROVED: 'Awaiting Cashier',
|
||||
REJECTED: 'Rejected',
|
||||
CLOSED: 'Closed'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="crv">
|
||||
<!-- ── HEADER ── -->
|
||||
<nav class="cashier-nav cashier-nav--clerk">
|
||||
<span class="crv-header-title">Closure Review</span>
|
||||
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
|
||||
</nav>
|
||||
|
||||
<!-- ── LOADING ── -->
|
||||
@if (loading) {
|
||||
<div class="crv-loading">
|
||||
<mat-icon class="crv-loading__icon">hourglass_empty</mat-icon>
|
||||
<span>Loading request…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── NOT FOUND ── -->
|
||||
@if (!loading && !request) {
|
||||
<div class="crv-loading">
|
||||
<mat-icon class="crv-loading__icon">error_outline</mat-icon>
|
||||
<span>Request not found.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── MAIN CONTENT ── -->
|
||||
@if (!loading && request) {
|
||||
<div class="crv-body">
|
||||
<!-- Header card -->
|
||||
<div class="crv-card crv-card--header">
|
||||
<div class="crv-card-header">
|
||||
<div class="crv-card-header__left">
|
||||
<span class="crv-account cell-mono">{{ request.account_no }}</span>
|
||||
<span class="crv-status" [ngClass]="statusClass(request.status)">{{ request.status }}</span>
|
||||
</div>
|
||||
<div class="crv-card-header__meta">
|
||||
<span class="crv-meta-item">
|
||||
<mat-icon>person</mat-icon>
|
||||
{{ request.clerk_name }} ({{ request.clerk_username }})
|
||||
</span>
|
||||
<span class="crv-meta-item">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ formatDateTime(request.submitted_at) }}
|
||||
</span>
|
||||
@if (request.actioned_at) {
|
||||
<span class="crv-meta-item">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Actioned: {{ formatDateTime(request.actioned_at) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closure details -->
|
||||
<div class="crv-card">
|
||||
<div class="crv-section-title">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
Closure Details
|
||||
</div>
|
||||
<div class="crv-detail-grid">
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Client</span>
|
||||
<span class="crv-detail-value">{{ request.client_name }}</span>
|
||||
</div>
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Payout Mode</span>
|
||||
<span class="crv-detail-value">{{ request.payment_type_name || 'Cash' }}</span>
|
||||
</div>
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Token No</span>
|
||||
<span class="crv-detail-value cell-mono">{{ request.tok_no || '—' }}</span>
|
||||
</div>
|
||||
@if (request.description) {
|
||||
<div class="crv-detail-item crv-detail-item--full">
|
||||
<span class="crv-detail-label">Reason / Narration</span>
|
||||
<span class="crv-detail-value">{{ request.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Computed amounts (once approved) -->
|
||||
@if (request.closing_amount != null) {
|
||||
<div class="crv-card crv-card--approved">
|
||||
<div class="crv-section-title">
|
||||
<mat-icon>calculate</mat-icon>
|
||||
Computed at Approval
|
||||
</div>
|
||||
<div class="crv-detail-grid">
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Exact Closing Amount</span>
|
||||
<span class="crv-detail-value crv-amount">₹{{ request.closing_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (request.rounding_amount && request.rounding_amount !== 0) {
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Rounding ({{ request.rounding_amount > 0 ? 'up' : 'down' }})</span>
|
||||
<span class="crv-detail-value"
|
||||
>{{ request.rounding_amount > 0 ? '+' : '' }}₹{{ request.rounding_amount | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Cash to Hand Over</span>
|
||||
<span class="crv-detail-value crv-amount">₹{{ request.cashier_disbursed_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (request.voucher_no) {
|
||||
<div class="crv-detail-item">
|
||||
<span class="crv-detail-label">Voucher No</span>
|
||||
<span class="crv-detail-value cell-mono">{{ request.voucher_no }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Rejection reason (if rejected) -->
|
||||
@if (request.status === 'REJECTED' && request.rejection_reason) {
|
||||
<div class="crv-card crv-card--rejected">
|
||||
<div class="crv-section-title">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Rejection Reason
|
||||
</div>
|
||||
<p class="crv-rejection-text">{{ request.rejection_reason }}</p>
|
||||
<div class="crv-meta-item" style="margin: 0 22px 14px">
|
||||
<mat-icon>manage_accounts</mat-icon>
|
||||
{{ request.manager_name || request.manager_username || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action buttons (only for PENDING) -->
|
||||
@if (request.status === 'PENDING') {
|
||||
<div class="crv-actions">
|
||||
@if (!showRejectForm) {
|
||||
<button type="button" class="crv-btn crv-btn--approve" [disabled]="acting" (click)="approve()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ acting ? 'Processing…' : 'Approve Closure' }}
|
||||
</button>
|
||||
<button type="button" class="crv-btn crv-btn--reject" [disabled]="acting" (click)="showRejectForm = true">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
} @else {
|
||||
<div class="crv-reject-form">
|
||||
<label class="crv-reject-label">Rejection Reason</label>
|
||||
<textarea
|
||||
class="crv-reject-textarea"
|
||||
[(ngModel)]="rejectionReason"
|
||||
placeholder="State the reason for rejection…"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="crv-reject-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="crv-btn crv-btn--reject"
|
||||
[disabled]="acting || !rejectionReason.trim()"
|
||||
(click)="reject()"
|
||||
>
|
||||
<mat-icon>cancel</mat-icon>
|
||||
{{ acting ? 'Processing…' : 'Confirm Rejection' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="crv-btn crv-btn--ghost"
|
||||
[disabled]="acting"
|
||||
(click)="showRejectForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$brand: #0f4f3c;
|
||||
$brand-light: #e6f4f0;
|
||||
$success: #16a34a;
|
||||
$success-bg: #f0fdf4;
|
||||
$danger: #dc2626;
|
||||
$danger-bg: #fef2f2;
|
||||
$warning: #d97706;
|
||||
$warning-bg: #fffbeb;
|
||||
$text-dark: #0f172a;
|
||||
$text-mid: #475569;
|
||||
$text-soft: #94a3b8;
|
||||
$border: #e2e8f0;
|
||||
$bg-page: #f1f5f9;
|
||||
$bg-white: #fff;
|
||||
$radius: 12px;
|
||||
$shadow-sm: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
$shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
width: min(680px, 92vw);
|
||||
border-radius: 10px;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
.cashier-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: $bg-white;
|
||||
border-bottom: 1px solid $border;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&__esc-hint {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: $text-soft;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: $bg-page;
|
||||
color: $text-mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crv-header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-mid;
|
||||
}
|
||||
|
||||
.crv-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
font-size: 15px;
|
||||
color: $text-soft;
|
||||
|
||||
&__icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: $text-soft;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-body {
|
||||
padding: 20px 20px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.crv-card {
|
||||
background: $bg-white;
|
||||
border-radius: $radius;
|
||||
border: 1px solid $border;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
|
||||
&--header {
|
||||
border-left: 4px solid $brand;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
border-left: 4px solid $success;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
border-left: 4px solid $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-card-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
color: $text-mid;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-account {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $brand;
|
||||
letter-spacing: 0.04em;
|
||||
background: $brand-light;
|
||||
padding: 4px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.crv-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--pending {
|
||||
background: $warning-bg;
|
||||
color: $warning;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: $success-bg;
|
||||
color: $success;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
color: $text-mid;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: $text-soft;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: $text-mid;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
border-bottom: 1px solid $border;
|
||||
background: linear-gradient(90deg, #f8fafc 0%, $bg-white 100%);
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $brand;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
padding: 16px 20px;
|
||||
gap: 14px 24px;
|
||||
}
|
||||
|
||||
.crv-detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.crv-detail-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: $text-soft;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.crv-detail-value {
|
||||
font-size: 14px;
|
||||
color: $text-dark;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.crv-amount {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
color: $success;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.crv-rejection-text {
|
||||
padding: 12px 20px;
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: $danger;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.crv-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.crv-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
padding: 11px 22px;
|
||||
letter-spacing: 0.01em;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&--approve {
|
||||
background: linear-gradient(135deg, $brand 0%, #1a7a5e 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgb(15 79 60 / 35%);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgb(15 79 60 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
border: 1.5px solid #fca5a5;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: #f1f5f9;
|
||||
color: $text-mid;
|
||||
border: 1px solid $border;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
color: $text-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.crv-reject-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: $danger-bg;
|
||||
border: 1.5px solid #fca5a5;
|
||||
border-radius: $radius;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.crv-reject-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: $danger;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.crv-reject-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1.5px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
resize: vertical;
|
||||
color: $text-dark;
|
||||
outline: none;
|
||||
background: $bg-white;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: $danger;
|
||||
box-shadow: 0 0 0 3px rgb(220 38 38 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.crv-reject-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkClosureRequestService } from '../../../../cashier/clerk-closure-request.service';
|
||||
|
||||
/** Manager's Approve/Reject review of one Cash closure request — opened as a MatDialog popup from
|
||||
* the Closure Requests tab, mirroring DisbursementReviewComponent's shape. Approving here is what
|
||||
* actually runs Fineract's close command (pro-rata interest + TDS + the real GL withdrawal all
|
||||
* post at this moment); a cashier then hands over the rounded cash figure against the token. */
|
||||
@Component({
|
||||
selector: 'mifosx-closure-review-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatSnackBarModule,
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './closure-review-dialog.component.html',
|
||||
styleUrl: './closure-review-dialog.component.scss'
|
||||
})
|
||||
export class ClosureReviewDialogComponent implements OnInit {
|
||||
private service = inject(ClerkClosureRequestService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialogRef = inject(MatDialogRef<ClosureReviewDialogComponent>);
|
||||
private dialogData = inject<{ requestId: number }>(MAT_DIALOG_DATA);
|
||||
|
||||
requestId: number | null = null;
|
||||
request: any = null;
|
||||
|
||||
loading = false;
|
||||
acting = false;
|
||||
showRejectForm = false;
|
||||
rejectionReason = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.requestId = this.dialogData?.requestId ?? null;
|
||||
if (this.requestId) this.loadRequest();
|
||||
}
|
||||
|
||||
loadRequest(): void {
|
||||
if (!this.requestId) return;
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getById(this.requestId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.request = res;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
approve(): void {
|
||||
if (!this.requestId || this.acting) return;
|
||||
this.acting = true;
|
||||
this.service
|
||||
.approve(this.requestId)
|
||||
.pipe(catchError((err) => of({ error: err?.error?.errors?.[0]?.defaultUserMessage ?? 'Approval failed.' })))
|
||||
.subscribe((res: any) => {
|
||||
this.acting = false;
|
||||
if (res?.error) {
|
||||
this.snackBar.open(res.error, 'Close', { duration: 6000 });
|
||||
} else {
|
||||
// A nil-balance closure has no cash to hand over, so the backend closes it outright
|
||||
// rather than parking it in the cashier's token queue.
|
||||
const msg =
|
||||
res?.status === 'CLOSED'
|
||||
? 'Account closed. Nil balance — no cash hand-over was required.'
|
||||
: 'Closure approved — ₹' +
|
||||
this.formatAmount(res?.cashierDisbursedAmount) +
|
||||
' is ready for the cashier to hand over against the token.';
|
||||
this.snackBar.open(msg, 'Close', { duration: 6000 });
|
||||
this.close(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
if (!this.requestId || this.acting || !this.rejectionReason.trim()) return;
|
||||
this.acting = true;
|
||||
this.service
|
||||
.reject(this.requestId, this.rejectionReason.trim())
|
||||
.pipe(catchError((err) => of({ error: err?.error?.errors?.[0]?.defaultUserMessage ?? 'Rejection failed.' })))
|
||||
.subscribe((res: any) => {
|
||||
this.acting = false;
|
||||
if (res?.error) {
|
||||
this.snackBar.open(res.error, 'Close', { duration: 6000 });
|
||||
} else {
|
||||
this.snackBar.open('Closure request rejected. The account remains Active and untouched.', 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
this.close(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(refreshed = false): void {
|
||||
this.dialogRef.close({ refreshed });
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'crv-status--pending',
|
||||
APPROVED: 'crv-status--approved',
|
||||
REJECTED: 'crv-status--rejected',
|
||||
CLOSED: 'crv-status--closed'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
formatAmount(val: any): string {
|
||||
const n = Number(val ?? 0);
|
||||
return n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,15 @@
|
||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||
<span>Cheque Book Requests</span>
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
routerLink="./closure-requests"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
||||
<span>Closure Requests</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="ac-tab-panel">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DividendRequestsComponent } from './checker-inbox-and-tasks-tabs/divide
|
||||
import { ShareAdditionRequestsComponent } from './checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component';
|
||||
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
|
||||
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
|
||||
import { ClosureRequestsComponent } from './checker-inbox-and-tasks-tabs/closure-requests/closure-requests.component';
|
||||
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
||||
import { LoanApprovalComponent } from './checker-inbox-and-tasks-tabs/loan-approval/loan-approval.component';
|
||||
import { LoanDisbursalComponent } from './checker-inbox-and-tasks-tabs/loan-disbursal/loan-disbursal.component';
|
||||
@@ -201,6 +202,11 @@ const routes: Routes = [
|
||||
path: 'chequebook-requests',
|
||||
component: ChequebookRequestsComponent,
|
||||
data: { title: 'Cheque Book Requests' }
|
||||
},
|
||||
{
|
||||
path: 'closure-requests',
|
||||
component: ClosureRequestsComponent,
|
||||
data: { title: 'Closure Requests' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user