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:
Jaanu423
2026-06-16 10:43:29 +05:30
co-authored by Claude Sonnet 4.6
parent 89d1167145
commit efb01d5441
18 changed files with 2594 additions and 27 deletions
@@ -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);
}
}
}