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
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user