feat(bank-transfer): convert bank transfer to maker-checker approval flow
- submitBankTransfer() now calls submit() with requestType='BANK_TRANSFER' instead of the immediate-execution /banktransfer endpoint - Confirm dialog updated: 'Submit for Approval' instead of 'Execute Transfer' - disbursement-review: isBankTransferMode getter, separate DR/CR entry tables, 'Approve Bank Transfer' button label, correct isCollectionMode guard - Disbursement Requests checker inbox: Type badge (Bank Transfer/Collection/ Disbursement) column, GL column shows 'Intra-bank transfer' for BT requests - Checker inbox tab renamed to 'Disbursement & Transfers' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e661161eac
commit
cbb2bd7c58
@@ -1065,10 +1065,10 @@ 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') {
|
||||
// Disbursement/Bank Transfer: auto-add mirror CR row when active row is DR
|
||||
if ((active?.typ === 'Disbursement' || active?.typ === 'Enter Bank Transfer') && active.dbCr === 'Dr') {
|
||||
const mirror: TxnRow = {
|
||||
typ: 'Disbursement',
|
||||
typ: active.typ,
|
||||
br: active.br ?? 1,
|
||||
trTp: '',
|
||||
acTyp: 'SD',
|
||||
@@ -1125,6 +1125,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (!row.dbCr) row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||
break;
|
||||
case 'Enter Bank Transfer':
|
||||
if (!row.dbCr) row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,6 +1282,69 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Submits an intra-bank book transfer for maker-checker approval. */
|
||||
private submitBankTransfer(): void {
|
||||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||
const firstRow = drRows[0] ?? crRows[0];
|
||||
|
||||
const toEntry = (r: TxnRow, dbCr: 'Dr' | 'Cr') => ({
|
||||
dbCr,
|
||||
accountNo: r.acNo,
|
||||
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
|
||||
accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||||
savingsAccountId: this.rowAccountIds[r.acNo],
|
||||
amount: String(r.amount),
|
||||
description: r.description ?? null
|
||||
});
|
||||
|
||||
const allEntries = [
|
||||
...drRows.map((r) => toEntry(r, 'Dr')),
|
||||
...crRows.map((r) => toEntry(r, 'Cr'))
|
||||
];
|
||||
|
||||
const payload = {
|
||||
requestType: 'BANK_TRANSFER',
|
||||
officeId: firstRow?.br ?? 1,
|
||||
currencyCode: 'INR',
|
||||
description: firstRow?.description || undefined,
|
||||
creditEntries: JSON.stringify(allEntries)
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.clerkDisbursementService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.message ??
|
||||
'Bank transfer 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) {
|
||||
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||
this.showAlert('success', 'Bank Transfer Submitted', [
|
||||
`Voucher No: ${res.voucherNo}`,
|
||||
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} · DR: ${drRows.length} account(s) → CR: ${crRows.length} account(s)`,
|
||||
'Pending Manager approval. Track status in "My Disbursement Requests".'
|
||||
]);
|
||||
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. */
|
||||
onTokNoBlur(row: TxnRow): void {
|
||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||
@@ -2217,6 +2284,80 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution
|
||||
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Enter Bank Transfer')) {
|
||||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||
|
||||
if (drRows.length === 0) {
|
||||
this.showAlert('error', 'DR Row Missing', ['At least one DR row (debit account) is required.']);
|
||||
return;
|
||||
}
|
||||
if (crRows.length === 0) {
|
||||
this.showAlert('error', 'CR Row Missing', ['At least one CR row (credit account) is required.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||
const allowedTypes = new Set([
|
||||
'SD',
|
||||
'CA',
|
||||
'CB'
|
||||
]);
|
||||
const invalidAccounts = [
|
||||
...drRows,
|
||||
...crRows
|
||||
].filter((r) => !allowedTypes.has(r.acTyp));
|
||||
if (invalidAccounts.length > 0) {
|
||||
this.showAlert('error', 'Invalid Account Type', [
|
||||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||||
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
||||
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||
|
||||
if (Math.abs(totalDr - totalCr) > 0.005) {
|
||||
this.showAlert('error', 'Unbalanced Entry', [
|
||||
`DR ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
'Total DR must equal total CR for a bank transfer.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// All accounts must be loaded
|
||||
const unloaded = [
|
||||
...drRows,
|
||||
...crRows
|
||||
].filter((r) => !this.rowAccountIds[r.acNo]);
|
||||
if (unloaded.length > 0) {
|
||||
this.showAlert('error', 'Account Not Loaded', [
|
||||
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmLines = [
|
||||
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''}):`,
|
||||
...drRows.map(
|
||||
(r) => ` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||||
),
|
||||
`CR (${crRows.length} account${crRows.length !== 1 ? 's' : ''}):`,
|
||||
...crRows.map(
|
||||
(r) => ` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||||
),
|
||||
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
'Transfer requires Manager approval before execution.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Bank Transfer', confirmLines, 'Submit for Approval', () =>
|
||||
this.submitBankTransfer()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete)
|
||||
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
|
||||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||
|
||||
Reference in New Issue
Block a user