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:
Jaanu423
2026-06-17 14:46:26 +05:30
co-authored by Claude Sonnet 4.6
parent e661161eac
commit cbb2bd7c58
7 changed files with 401 additions and 85 deletions
@@ -45,4 +45,8 @@ export class ClerkDisbursementService {
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/disbursement-requests/${id}/reject`, { reason });
}
bankTransfer(data: any): Observable<any> {
return this.http.post('/clerk/disbursement-requests/banktransfer', data);
}
}
@@ -150,7 +150,12 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="row.acTyp === 'GL' && row.typ !== 'Disbursement' && (row.dbCr = 'Cr')"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
@@ -192,7 +197,9 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="row.acTyp === 'GL' && row.typ !== 'Disbursement'"
[disabled]="
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -1194,7 +1201,12 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(change)="row.acTyp === 'GL' && row.typ !== 'Disbursement' && (row.dbCr = 'Cr')"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
@@ -1232,7 +1244,9 @@
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' && row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -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');