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
@@ -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', [