feat(clerk): add GL disbursement maker-checker flow
- Add ClerkDisbursementService for submit/approve/reject HTTP calls - Add DisbursementRequestsComponent (manager inbox tab under checker tasks) - Add DisbursementReviewComponent (manager approve/reject detail page) - Add MyDisbursementRequestsComponent (clerk's own disbursement history) - Add 'My Disbursements' nav tab to clerk shell navigation header - Wire routes in cashier-routing and tasks-routing modules - Add 'My Disbursements' button to transaction-entry clerk footer - Include accountName in creditEntries payload for manager review display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
89d1167145
commit
efb01d5441
@@ -54,6 +54,14 @@ const routes: Routes = [
|
||||
loadComponent: () => import('../clerk/my-requests/my-requests.component').then((m) => m.MyRequestsComponent),
|
||||
data: { title: 'My Withdrawal Requests', breadcrumb: 'My Withdrawal Requests' }
|
||||
},
|
||||
{
|
||||
path: 'my-disbursement-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-disbursement-requests/my-disbursement-requests.component').then(
|
||||
(m) => m.MyDisbursementRequestsComponent
|
||||
),
|
||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** HTTP service for Clerk Disbursement Request maker-checker workflow. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkDisbursementService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/disbursement-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/disbursement-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/disbursement-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/disbursement-requests/${id}`);
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/disbursement-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/disbursement-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,12 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--type">
|
||||
<label class="ws-f__lbl" title="Transaction Type">TYPE</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.typ" (click)="$event.stopPropagation()">
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.typ"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onTypChange(row)"
|
||||
>
|
||||
<option value="~">~</option>
|
||||
<option value="Withdrawal Slip">Withdrawal Slip</option>
|
||||
<option value="Cheque">Cheque</option>
|
||||
@@ -145,7 +150,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
|
||||
(change)="row.acTyp === 'GL' && row.typ !== 'Disbursement' && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptions; track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -187,7 +192,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="row.acTyp === 'GL'"
|
||||
[disabled]="row.acTyp === 'GL' && row.typ !== 'Disbursement'"
|
||||
(click)="row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
@@ -236,24 +241,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
[(ngModel)]="row.chequeNo"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl" title="Cheque Date">CHEQ. DATE</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.cheqDate"
|
||||
placeholder="dd-mm-yyyy"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
@if (row.typ !== 'Withdrawal Slip') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
[(ngModel)]="row.chequeNo"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl" title="Cheque Date">CHEQ. DATE</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.cheqDate"
|
||||
placeholder="dd-mm-yyyy"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl" title="Token Number">TOK. NO.</label>
|
||||
<input
|
||||
@@ -1187,7 +1194,7 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
|
||||
(change)="row.acTyp === 'GL' && row.typ !== 'Disbursement' && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptions; track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -1393,6 +1400,14 @@
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
class="ws-btn ws-btn--ghost"
|
||||
type="button"
|
||||
(click)="router.navigate(['/clerk/my-disbursement-requests'])"
|
||||
title="View my disbursement requests"
|
||||
>
|
||||
My Disbursements
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-footer__actions">
|
||||
|
||||
@@ -19,6 +19,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 { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||
import { CashierDepositService } from '../cashier-deposit.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
|
||||
@@ -654,8 +655,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private dialog = inject(MatDialog);
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||
private cashierDepositService = inject(CashierDepositService);
|
||||
private router = inject(Router);
|
||||
readonly router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
today = '';
|
||||
@@ -664,6 +666,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
loadingAccount = false;
|
||||
accountError = '';
|
||||
|
||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||
rowAccountIds: Record<string, number> = {};
|
||||
rowAccountNames: Record<string, string> = {};
|
||||
|
||||
// Full account + client data
|
||||
accountDetails: any = null;
|
||||
clientDetails: any = null;
|
||||
@@ -1058,6 +1064,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
addRow(): void {
|
||||
const active = this.selectedRow;
|
||||
// Disbursement: auto-add mirror CR row (same type/txntp, opposite DR/CR)
|
||||
if (active?.typ === 'Disbursement') {
|
||||
const mirror: TxnRow = {
|
||||
typ: 'Disbursement',
|
||||
br: active.br ?? 1,
|
||||
trTp: '',
|
||||
acTyp: 'SD',
|
||||
acNo: '',
|
||||
ecsAcNo: '',
|
||||
dbCr: 'Cr',
|
||||
chequeNo: '',
|
||||
cheqDate: '',
|
||||
amount: active.amount,
|
||||
description: active.description ?? '',
|
||||
tokNo: '',
|
||||
paymentTypeId: this.findTransferPaymentTypeId(),
|
||||
bankName: '',
|
||||
routingCode: ''
|
||||
};
|
||||
this.rows.push(mirror);
|
||||
this.selectedRow = mirror;
|
||||
return;
|
||||
}
|
||||
const row: TxnRow = {
|
||||
typ: '~',
|
||||
br: 1,
|
||||
@@ -1076,7 +1106,156 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
routingCode: ''
|
||||
};
|
||||
this.rows.push(row);
|
||||
// Token is assigned by the backend at actual submission time — not pre-generated here
|
||||
}
|
||||
|
||||
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
||||
onTypChange(row: TxnRow): void {
|
||||
switch (row.typ) {
|
||||
case 'Withdrawal Slip':
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
case 'Cheque':
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
break;
|
||||
case 'Disbursement':
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private findCashPaymentTypeId(): number | null {
|
||||
const pt = this.paymentTypes.find((p: any) => !!p.isCashPayment);
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
private findTransferPaymentTypeId(): number | null {
|
||||
const pt = this.paymentTypes.find((p: any) => /transfer|neft|rtgs|imps/i.test(p.name ?? ''));
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clerk double-entry disbursement (maker-checker):
|
||||
* 1 DR row (GL source) + 1..N CR rows (savings/current destinations).
|
||||
* Submits a PENDING request — manager must approve before balances are credited.
|
||||
*/
|
||||
private submitDisbursementDoubleEntry(): void {
|
||||
const drRow = this.rows.find((r) => r.dbCr === 'Dr');
|
||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||
|
||||
if (!drRow) {
|
||||
this.showAlert('error', 'DR Row Missing', ['One DR row (GL source) is required.']);
|
||||
return;
|
||||
}
|
||||
if (crRows.length === 0) {
|
||||
this.showAlert('error', 'CR Row Missing', ['At least one CR row (destination account) is required.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const drAmount = drRow.amount ?? 0;
|
||||
if (!drAmount || drAmount <= 0) {
|
||||
this.showAlert('error', 'Amount Required', ['Please enter the amount in the DR (GL) row.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all CR rows have amounts and accounts
|
||||
for (const cr of crRows) {
|
||||
if (!cr.acNo?.trim()) {
|
||||
this.showAlert('error', 'Account Required', ['Please fill account number in all CR rows.']);
|
||||
return;
|
||||
}
|
||||
if (!cr.amount || cr.amount <= 0) {
|
||||
this.showAlert('error', 'Amount Required', [`Please enter amount for CR row: ${cr.acNo}`]);
|
||||
return;
|
||||
}
|
||||
if (!cr.paymentTypeId) {
|
||||
this.showAlert('error', 'TXN TP Required', [`Please select payment type for CR row: ${cr.acNo}`]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Total CR must equal DR
|
||||
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||
if (Math.abs(drAmount - totalCr) > 0.005) {
|
||||
this.showAlert('error', 'Unbalanced Entry', [
|
||||
`DR ₹${drAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||||
'All CR amounts must sum to the DR amount.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Each CR row must have its account loaded (savingsAccountId required for approval)
|
||||
// We store accountId per row using a per-row map: acNo → accountId
|
||||
// For simplicity, require that the selected row is a CR row with accountDetails loaded
|
||||
// Build creditEntries from each CR row
|
||||
const creditEntries = crRows.map((cr) => ({
|
||||
accountNo: cr.acNo,
|
||||
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
|
||||
accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||||
savingsAccountId: this.rowAccountIds[cr.acNo] ?? null,
|
||||
amount: String(cr.amount),
|
||||
paymentTypeId: cr.paymentTypeId,
|
||||
description: cr.description ?? null
|
||||
}));
|
||||
|
||||
// Verify all CR accounts have been loaded
|
||||
const unloaded = crRows.filter((cr) => !this.rowAccountIds[cr.acNo]);
|
||||
if (unloaded.length > 0) {
|
||||
this.showAlert('error', 'Account Not Loaded', [
|
||||
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`,
|
||||
'Click on each CR row and wait for account details to load.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
glAccountId: this.rowAccountIds[drRow.acNo] ?? null,
|
||||
glAccountNo: drRow.acNo,
|
||||
glAccountName: this.rowAccountNames[drRow.acNo] ?? drRow.acNo,
|
||||
officeId: drRow.br ?? 1,
|
||||
currencyCode: 'INR',
|
||||
paymentTypeId: drRow.paymentTypeId ?? crRows[0]?.paymentTypeId,
|
||||
paymentTypeName:
|
||||
this.paymentTypes.find((p: any) => p.id === (drRow.paymentTypeId ?? crRows[0]?.paymentTypeId))?.name ?? null,
|
||||
description: drRow.description || crRows[0]?.description || null,
|
||||
creditEntries: JSON.stringify(creditEntries)
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.clerkDisbursementService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.message ??
|
||||
'Disbursement request submission failed. Please try again.';
|
||||
this.showAlert('error', 'Submission Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
this.showAlert('success', 'Disbursement Request Submitted', [
|
||||
`Voucher No: ${res.voucherNo} · Total: ₹${drAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`DR: GL — ${drRow.acNo} → ${crRows.length} CR account(s)`,
|
||||
'Pending manager approval. Balance will be credited after approval.'
|
||||
]);
|
||||
this.rows = [];
|
||||
this.rowAccountIds = {};
|
||||
this.rowAccountNames = {};
|
||||
this.addRow();
|
||||
this.selectedRow = this.rows[0];
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||
@@ -1384,6 +1563,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.accountDetails = acc;
|
||||
this.jointHolders = acc.jointHolders ?? [];
|
||||
// Track per-row account id/name for multi-row disbursement
|
||||
if (this.selectedRow?.acNo) {
|
||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||
this.rowAccountNames[this.selectedRow.acNo] =
|
||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||
}
|
||||
if (this.selectedRow) {
|
||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
||||
}
|
||||
@@ -2033,6 +2218,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
|
||||
// Clerk multi-row disbursement (GL DR + Savings/Current CR) — atomic double-entry
|
||||
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
|
||||
this.submitDisbursementDoubleEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
||||
if (this.isClerk && isDeposit && isCash) {
|
||||
this.showAlert('error', 'Cash Deposit Not Permitted', [
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<!--
|
||||
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="dri">
|
||||
<!-- ── NAV BAR ── -->
|
||||
<nav class="cashier-nav cashier-nav--clerk">
|
||||
<button type="button" class="cashier-nav__item cashier-nav__item--primary cashier-nav__item--active">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20M2 12h20" />
|
||||
</svg>
|
||||
Disbursement Review
|
||||
</button>
|
||||
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
|
||||
</nav>
|
||||
|
||||
<!-- ── LOADING ── -->
|
||||
@if (loading) {
|
||||
<div class="dri-loading">
|
||||
<mat-icon class="dri-loading__icon">hourglass_empty</mat-icon>
|
||||
<span>Loading request…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── NOT FOUND ── -->
|
||||
@if (!loading && !request) {
|
||||
<div class="dri-loading">
|
||||
<mat-icon class="dri-loading__icon">error_outline</mat-icon>
|
||||
<span>Request not found.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── MAIN CONTENT ── -->
|
||||
@if (!loading && request) {
|
||||
<div class="dri-body">
|
||||
<!-- Header card -->
|
||||
<div class="dri-card dri-card--header">
|
||||
<div class="dri-card-header">
|
||||
<div class="dri-card-header__left">
|
||||
<span class="dri-voucher">{{ request.voucher_no || '—' }}</span>
|
||||
<span class="dri-status" [ngClass]="statusClass(request.status)">{{ request.status }}</span>
|
||||
</div>
|
||||
<div class="dri-card-header__meta">
|
||||
<span class="dri-meta-item">
|
||||
<mat-icon>person</mat-icon>
|
||||
{{ request.clerk_name }} ({{ request.clerk_username }})
|
||||
</span>
|
||||
<span class="dri-meta-item">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ formatDateTime(request.submitted_at) }}
|
||||
</span>
|
||||
@if (request.actioned_at) {
|
||||
<span class="dri-meta-item">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Actioned: {{ formatDateTime(request.actioned_at) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GL Debit row -->
|
||||
<div class="dri-card">
|
||||
<div class="dri-section-title">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
GL Debit (Source)
|
||||
</div>
|
||||
<div class="dri-detail-grid">
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">GL Account No</span>
|
||||
<span class="dri-detail-value cell-mono">{{ request.gl_account_no }}</span>
|
||||
</div>
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">GL Account Name</span>
|
||||
<span class="dri-detail-value">{{ request.gl_account_name }}</span>
|
||||
</div>
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">DR Amount</span>
|
||||
<span class="dri-detail-value dri-amount">₹{{ request.dr_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">Payment Mode</span>
|
||||
<span class="dri-detail-value">{{ request.payment_type_name || 'Transfer' }}</span>
|
||||
</div>
|
||||
@if (request.description) {
|
||||
<div class="dri-detail-item dri-detail-item--full">
|
||||
<span class="dri-detail-label">Description</span>
|
||||
<span class="dri-detail-value">{{ request.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit entries -->
|
||||
<div class="dri-card">
|
||||
<div class="dri-section-title">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
Credit Entries ({{ creditEntries.length }} account{{ creditEntries.length !== 1 ? 's' : '' }})
|
||||
</div>
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Amount (CR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of creditEntries; track entry.accountId; let i = $index) {
|
||||
<tr>
|
||||
<td class="dri-cr-idx">{{ i + 1 }}</td>
|
||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||
<td>{{ entry.accountName }}</td>
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Rejection reason (if rejected) -->
|
||||
@if (request.status === 'REJECTED' && request.rejection_reason) {
|
||||
<div class="dri-card dri-card--rejected">
|
||||
<div class="dri-section-title">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Rejection Reason
|
||||
</div>
|
||||
<p class="dri-rejection-text">{{ request.rejection_reason }}</p>
|
||||
<div class="dri-meta-item" style="margin-top: 8px">
|
||||
<mat-icon>manage_accounts</mat-icon>
|
||||
{{ request.manager_name || request.manager_username || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Txn refs (if approved) -->
|
||||
@if (request.status === 'APPROVED' && request.txn_refs) {
|
||||
<div class="dri-card dri-card--approved">
|
||||
<div class="dri-section-title">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Transaction References
|
||||
</div>
|
||||
<p class="dri-txn-refs cell-mono">{{ request.txn_refs }}</p>
|
||||
<div class="dri-meta-item" style="margin-top: 8px">
|
||||
<mat-icon>manage_accounts</mat-icon>
|
||||
Approved by: {{ request.manager_name || request.manager_username || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action buttons (only for PENDING) -->
|
||||
@if (request.status === 'PENDING') {
|
||||
<div class="dri-actions">
|
||||
@if (!showRejectForm) {
|
||||
<button type="button" class="dri-btn dri-btn--approve" [disabled]="acting" (click)="approve()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ acting ? 'Processing…' : 'Approve Disbursement' }}
|
||||
</button>
|
||||
<button type="button" class="dri-btn dri-btn--reject" [disabled]="acting" (click)="showRejectForm = true">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
} @else {
|
||||
<div class="dri-reject-form">
|
||||
<label class="dri-reject-label">Rejection Reason</label>
|
||||
<textarea
|
||||
class="dri-reject-textarea"
|
||||
[(ngModel)]="rejectionReason"
|
||||
placeholder="State the reason for rejection…"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="dri-reject-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dri-btn dri-btn--reject"
|
||||
[disabled]="acting || !rejectionReason.trim()"
|
||||
(click)="reject()"
|
||||
>
|
||||
<mat-icon>cancel</mat-icon>
|
||||
{{ acting ? 'Processing…' : 'Confirm Rejection' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dri-btn dri-btn--ghost"
|
||||
[disabled]="acting"
|
||||
(click)="showRejectForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
.dri {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dri-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
|
||||
&__icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dri-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
|
||||
&--header {
|
||||
border-top: 3px solid #0f4f3c;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
border-top: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
border-top: 3px solid #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-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: 16px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-voucher {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dri-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
&--pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #f8fafc;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
padding: 16px 20px;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.dri-detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.dri-detail-value {
|
||||
font-size: 14px;
|
||||
color: #0d1b2a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dri-amount {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.dri-cr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-cr-idx {
|
||||
width: 36px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dri-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dri-cr-amount {
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.dri-cr-total-row {
|
||||
background: #f0fdf4;
|
||||
|
||||
td {
|
||||
border-top: 2px solid #bbf7d0;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-cr-total-label {
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dri-cr-total-amount {
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dri-rejection-text {
|
||||
padding: 14px 20px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dri-txn-refs {
|
||||
padding: 14px 20px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.dri-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.dri-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
padding: 10px 20px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--approve {
|
||||
background: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dri-reject-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.dri-reject-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.dri-reject-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
color: #0d1b2a;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.dri-reject-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkDisbursementService } from '../../cashier/clerk-disbursement.service';
|
||||
|
||||
interface CreditEntry {
|
||||
accountId: number;
|
||||
accountNo: string;
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-disbursement-review',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatSnackBarModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './disbursement-review.component.html',
|
||||
styleUrl: './disbursement-review.component.scss'
|
||||
})
|
||||
export class DisbursementReviewComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private service = inject(ClerkDisbursementService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
requestId: number | null = null;
|
||||
request: any = null;
|
||||
creditEntries: CreditEntry[] = [];
|
||||
|
||||
loading = false;
|
||||
acting = false;
|
||||
showRejectForm = false;
|
||||
rejectionReason = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.params['id'];
|
||||
this.requestId = id != null ? Number(id) : 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;
|
||||
if (res?.credit_entries) {
|
||||
try {
|
||||
this.creditEntries =
|
||||
typeof res.credit_entries === 'string' ? JSON.parse(res.credit_entries) : res.credit_entries;
|
||||
} catch {
|
||||
this.creditEntries = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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: 5000 });
|
||||
} else {
|
||||
this.snackBar.open('Disbursement approved successfully.', 'Close', { duration: 3000 });
|
||||
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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: 5000 });
|
||||
} else {
|
||||
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
|
||||
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
totalCreditAmount(): number {
|
||||
return this.creditEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'dri-status--pending',
|
||||
APPROVED: 'dri-status--approved',
|
||||
REJECTED: 'dri-status--rejected'
|
||||
};
|
||||
return map[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);
|
||||
}
|
||||
}
|
||||
|
||||
backToInbox(): void {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<!--
|
||||
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="mdr-page">
|
||||
<!-- ── HEADER ── -->
|
||||
<div class="mdr-header">
|
||||
<div class="mdr-header__left">
|
||||
<mat-icon class="mdr-header__icon">swap_horiz</mat-icon>
|
||||
<div>
|
||||
<div class="mdr-header__title">My Disbursement Requests</div>
|
||||
<div class="mdr-header__sub">Track your submitted GL disbursement requests.</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="mdr-btn mdr-btn--new" (click)="newDisbursement()">
|
||||
<mat-icon>add</mat-icon>
|
||||
New Disbursement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── FILTER BAR ── -->
|
||||
<div class="mdr-filter-bar">
|
||||
<select class="mdr-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="mdr-fb-sep"></span>
|
||||
|
||||
<div class="mdr-presets">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="mdr-preset-chip"
|
||||
[class.mdr-preset-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="mdr-fb-sep"></span>
|
||||
|
||||
<div class="mdr-date-range">
|
||||
<div class="mdr-date-box">
|
||||
<span class="mdr-date-box__lbl">From</span>
|
||||
<input
|
||||
class="mdr-date-box__input"
|
||||
[matDatepicker]="fromPicker"
|
||||
[(ngModel)]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onFromDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="mdr-date-box__btn" (click)="fromPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</div>
|
||||
<span class="mdr-date-range__sep">—</span>
|
||||
<div class="mdr-date-box">
|
||||
<span class="mdr-date-box__lbl">To</span>
|
||||
<input
|
||||
class="mdr-date-box__input"
|
||||
[matDatepicker]="toPicker"
|
||||
[(ngModel)]="toDate"
|
||||
[min]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onToDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="mdr-date-box__btn" (click)="toPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mdr-btn mdr-btn--refresh" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── TABLE ── -->
|
||||
@if (loading) {
|
||||
<div class="mdr-empty">
|
||||
<p class="mdr-empty__title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<div class="mdr-empty">
|
||||
<i class="fas fa-inbox mdr-empty__icon"></i>
|
||||
<p class="mdr-empty__title">No disbursement requests found</p>
|
||||
<p class="mdr-empty__text">You have not submitted any disbursement requests yet.</p>
|
||||
<button type="button" class="mdr-btn mdr-btn--new" style="margin-top: 12px" (click)="newDisbursement()">
|
||||
<mat-icon>add</mat-icon>
|
||||
New Disbursement
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mdr-table-wrap">
|
||||
<table class="mdr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>GL Account</th>
|
||||
<th class="num">DR Amount</th>
|
||||
<th>Credit Entries</th>
|
||||
<th>Payment Mode</th>
|
||||
<th>Reviewed By</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of requests; track r.id) {
|
||||
<tr class="mdr-row">
|
||||
<td class="cell-mono mdr-voucher">{{ r.voucher_no || '—' }}</td>
|
||||
<td>
|
||||
<span class="cell-mono">{{ r.gl_account_no }}</span>
|
||||
<span class="mdr-gl-name">{{ r.gl_account_name }}</span>
|
||||
</td>
|
||||
<td class="num mdr-amount">₹{{ r.dr_amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="mdr-credit-badge">{{ creditCount(r) }} account{{ creditCount(r) !== 1 ? 's' : '' }}</span>
|
||||
</td>
|
||||
<td>{{ r.payment_type_name || 'Transfer' }}</td>
|
||||
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||
<td class="mdr-dt">{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span class="mdr-status" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@if (r.status === 'REJECTED' && r.rejection_reason) {
|
||||
<tr class="mdr-reason-row">
|
||||
<td colspan="8" class="mdr-reason-cell">
|
||||
<mat-icon class="mdr-reason-icon">info</mat-icon>
|
||||
{{ r.rejection_reason }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
.mdr-page {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mdr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mdr-fb-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.mdr-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;
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-presets {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mdr-preset-chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #0f4f3c;
|
||||
color: #0f4f3c;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #0f4f3c;
|
||||
border-color: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-date-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__sep {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-date-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 76px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #0f4f3c;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--new {
|
||||
background: #0f4f3c;
|
||||
color: #fff;
|
||||
border-color: #0f4f3c;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
|
||||
&--refresh {
|
||||
padding: 0 8px;
|
||||
color: #64748b;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mdr-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;
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-row {
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover td {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.mdr-amount {
|
||||
font-weight: 700;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
.mdr-dt {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mdr-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;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mdr-voucher {
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.mdr-gl-name {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mdr-credit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mdr-reason-row td {
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.mdr-reason-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
font-style: italic;
|
||||
padding: 6px 14px 10px;
|
||||
}
|
||||
|
||||
.mdr-reason-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mdr-empty {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
color: #94a3b8;
|
||||
|
||||
&__icon {
|
||||
font-size: 40px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkDisbursementService } from '../../cashier/clerk-disbursement.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-my-disbursement-requests',
|
||||
templateUrl: './my-disbursement-requests.component.html',
|
||||
styleUrls: ['./my-disbursement-requests.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatPaginatorModule,
|
||||
MatNativeDateModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatDatepickerModule
|
||||
]
|
||||
})
|
||||
export class MyDisbursementRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkDisbursementService);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
|
||||
filterStatus = '';
|
||||
fromDate: Date | null = null;
|
||||
toDate: Date | null = null;
|
||||
maxDate!: Date;
|
||||
activePreset = 'today';
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
readonly datePresets = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'week', label: 'This Week' },
|
||||
{ key: 'month', label: 'This Month' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
this.maxDate = today;
|
||||
this.applyPreset('today');
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(
|
||||
this.filterStatus || undefined,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize,
|
||||
this.isoDate(this.fromDate),
|
||||
this.isoDate(this.toDate)
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFromDateChange(): void {
|
||||
this.activePreset = '';
|
||||
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||
this.toDate = new Date(this.fromDate);
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onToDateChange(): void {
|
||||
this.activePreset = '';
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = new Date(today);
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'week') {
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - today.getDay());
|
||||
this.fromDate = start;
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'month') {
|
||||
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
this.toDate = new Date(today);
|
||||
} else {
|
||||
this.fromDate = null;
|
||||
this.toDate = null;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
newDisbursement(): void {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
|
||||
creditCount(req: any): number {
|
||||
try {
|
||||
const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries;
|
||||
return Array.isArray(arr) ? arr.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'mdr-status--pending',
|
||||
APPROVED: 'mdr-status--approved',
|
||||
REJECTED: 'mdr-status--rejected'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,17 @@
|
||||
</svg>
|
||||
My Requests
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/my-disbursement-requests')"
|
||||
[routerLink]="['/clerk/my-disbursement-requests']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
My Disbursements
|
||||
</a>
|
||||
@if (clerkAccountLoaded) {
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
|
||||
@@ -135,7 +135,8 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
url.includes('/clerk/photo-signature') ||
|
||||
url.includes('/clerk/standing-instructions') ||
|
||||
url.includes('/clerk/today-activity') ||
|
||||
url.includes('/clerk/withdrawal-request');
|
||||
url.includes('/clerk/withdrawal-request') ||
|
||||
url.includes('/clerk/my-disbursement-requests');
|
||||
this.hideShellLayout = isCashierOnly || isClerkRoute || this.isWithdrawalReviewUrl;
|
||||
this.cdr.detectChanges();
|
||||
};
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
<!--
|
||||
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">swap_horiz</mat-icon>
|
||||
<div>
|
||||
<div class="wri-action-bar__title">Disbursement Requests</div>
|
||||
<div class="wri-action-bar__sub">Review, approve or reject clerk GL disbursement requests.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FILTER BAR ── -->
|
||||
<div class="wri-filter-bar">
|
||||
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="wri-fb-sep"></span>
|
||||
|
||||
<div class="wri-presets">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="wri-preset-chip"
|
||||
[class.wri-preset-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="wri-fb-sep"></span>
|
||||
|
||||
<div class="wri-date-range">
|
||||
<div class="wri-date-box">
|
||||
<span class="wri-date-box__lbl">From</span>
|
||||
<input
|
||||
class="wri-date-box__input"
|
||||
[matDatepicker]="fromPicker"
|
||||
[(ngModel)]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onFromDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="wri-date-box__btn" (click)="fromPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</div>
|
||||
<span class="wri-date-range__sep">—</span>
|
||||
<div class="wri-date-box">
|
||||
<span class="wri-date-box__lbl">To</span>
|
||||
<input
|
||||
class="wri-date-box__input"
|
||||
[matDatepicker]="toPicker"
|
||||
[(ngModel)]="toDate"
|
||||
[min]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onToDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="wri-date-box__btn" (click)="toPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── TABLE ── -->
|
||||
@if (loading) {
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-inbox empty-icon"></i>
|
||||
<p class="empty-title">No disbursement requests</p>
|
||||
<p class="empty-text">
|
||||
@if (filterStatus === 'PENDING') {
|
||||
There are no pending disbursement requests waiting for review.
|
||||
} @else {
|
||||
No {{ filterStatus | lowercase }} disbursement requests found.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wri-table-wrap">
|
||||
<table class="wri-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wri-th-id">Voucher No</th>
|
||||
<th>GL Account</th>
|
||||
<th class="num">DR Amount</th>
|
||||
<th>Credit Entries</th>
|
||||
<th>Payment Mode</th>
|
||||
<th>Clerk</th>
|
||||
<th>Reviewed By</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 wri-voucher">{{ r.voucher_no || '—' }}</td>
|
||||
<td>
|
||||
<span class="cell-mono">{{ r.gl_account_no }}</span>
|
||||
<span class="wri-gl-name">{{ r.gl_account_name }}</span>
|
||||
</td>
|
||||
<td class="num wri-amount">₹{{ r.dr_amount | number: '1.2-2' }}</td>
|
||||
<td class="wri-credit-count">
|
||||
<span class="wri-credit-badge">{{ creditCount(r) }} account{{ creditCount(r) !== 1 ? 's' : '' }}</span>
|
||||
</td>
|
||||
<td>{{ r.payment_type_name || 'Transfer' }}</td>
|
||||
<td>{{ r.clerk_name }}</td>
|
||||
<td>{{ r.manager_name || r.manager_username || '—' }}</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>
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 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-presets {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wri-preset-chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #0f4f3c;
|
||||
color: #0f4f3c;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #0f4f3c;
|
||||
border-color: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wri-date-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__sep {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-date-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 76px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #0f4f3c;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wri-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #0d1b2a;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--refresh {
|
||||
padding: 0 8px;
|
||||
color: #64748b;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
&--review {
|
||||
background: #0f4f3c;
|
||||
color: #fff;
|
||||
border-color: #0f4f3c;
|
||||
|
||||
&:hover {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wri-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wri-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover td {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.wri-th-id {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.wri-th-action {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wri-action-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wri-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;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wri-voucher {
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.wri-gl-name {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkDisbursementService } from '../../../cashier/clerk-disbursement.service';
|
||||
import { SettingsService } from '../../../settings/settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-disbursement-requests',
|
||||
templateUrl: './disbursement-requests.component.html',
|
||||
styleUrls: ['./disbursement-requests.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule,
|
||||
MatNativeDateModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatDatepickerModule
|
||||
]
|
||||
})
|
||||
export class DisbursementRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkDisbursementService);
|
||||
private router = inject(Router);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
fromDate: Date | null = null;
|
||||
toDate: Date | null = null;
|
||||
maxDate!: Date;
|
||||
activePreset = 'today';
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
readonly datePresets = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'week', label: 'This Week' },
|
||||
{ key: 'month', label: 'This Month' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
this.maxDate = today;
|
||||
this.applyPreset('today');
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getPendingRequests(
|
||||
this.filterStatus,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize,
|
||||
this.isoDate(this.fromDate),
|
||||
this.isoDate(this.toDate)
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFromDateChange(): void {
|
||||
this.activePreset = '';
|
||||
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||
this.toDate = new Date(this.fromDate);
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onToDateChange(): void {
|
||||
this.activePreset = '';
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = new Date(today);
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'week') {
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - today.getDay());
|
||||
this.fromDate = start;
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'month') {
|
||||
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
this.toDate = new Date(today);
|
||||
} else {
|
||||
this.fromDate = null;
|
||||
this.toDate = null;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
review(req: any): void {
|
||||
this.router.navigate([
|
||||
'/checker-inbox-and-tasks/disbursement-requests',
|
||||
req.id,
|
||||
'review'
|
||||
]);
|
||||
}
|
||||
|
||||
creditCount(req: any): number {
|
||||
try {
|
||||
const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries;
|
||||
return Array.isArray(arr) ? arr.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'wri-status--pending',
|
||||
APPROVED: 'wri-status--approved',
|
||||
REJECTED: 'wri-status--rejected'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,18 @@
|
||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||
<span>Deposit Requests</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./disbursement-requests']"
|
||||
routerLinkActive
|
||||
#disbursementRequests="routerLinkActive"
|
||||
[active]="disbursementRequests.isActive"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<span>Disbursement Requests</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<mat-tab-nav-panel #tabPanel>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker
|
||||
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
||||
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
|
||||
import { 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 { 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';
|
||||
@@ -113,6 +114,11 @@ const routes: Routes = [
|
||||
path: 'deposit-requests',
|
||||
component: DepositRequestsComponent,
|
||||
data: { title: 'Deposit Requests' }
|
||||
},
|
||||
{
|
||||
path: 'disbursement-requests',
|
||||
component: DisbursementRequestsComponent,
|
||||
data: { title: 'Disbursement Requests' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -139,6 +145,19 @@ const routes: Routes = [
|
||||
data: { title: 'Review Withdrawal Request', breadcrumb: 'Review Withdrawal' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'checker-inbox-and-tasks/disbursement-requests',
|
||||
children: [
|
||||
{
|
||||
path: ':id/review',
|
||||
loadComponent: () =>
|
||||
import('../clerk/disbursement-review/disbursement-review.component').then(
|
||||
(m) => m.DisbursementReviewComponent
|
||||
),
|
||||
data: { title: 'Review Disbursement Request', breadcrumb: 'Review Disbursement' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260615',
|
||||
'hash': 'e5d6cd32'
|
||||
'version': '260616',
|
||||
'hash': '89d11671'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user