feat(clerk): add collection flow — multi-DR savings to CR GL with maker-checker
- Transaction entry: detect collection mode (N DR savings → 1 CR GL) and disbursement mode (1 DR GL → N CR savings); build dbCr-tagged payload for backend - Disbursement review: show 'GL Credit (Target)' / 'Debit Entries' labels for collection; fix double-count by summing only DR entries for collection totals; dynamic approve button text - My disbursement requests: add DR/CR badge column in expanded credit-entries preview - My requests / withdrawal-requests: minor consistency updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
efb01d5441
commit
6cccb58c04
@@ -330,7 +330,7 @@
|
|||||||
<span>Approved by: {{ activeWithdrawalRequest.manager_name || '—' }}</span>
|
<span>Approved by: {{ activeWithdrawalRequest.manager_name || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-withdrawal-banner__note">
|
<div class="ws-withdrawal-banner__note">
|
||||||
After transaction succeeds, this request will be automatically marked <strong>DISBURSED</strong>.
|
After transaction succeeds, this request will be automatically marked <strong>WITHDRAWN</strong>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeWithdrawalRequest = null" title="Unlink">
|
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeWithdrawalRequest = null" title="Unlink">
|
||||||
|
|||||||
@@ -1122,7 +1122,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||||
break;
|
break;
|
||||||
case 'Disbursement':
|
case 'Disbursement':
|
||||||
row.dbCr = 'Dr';
|
if (!row.dbCr) row.dbCr = 'Dr';
|
||||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1139,90 +1139,106 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clerk double-entry disbursement (maker-checker):
|
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||||
* 1 DR row (GL source) + 1..N CR rows (savings/current destinations).
|
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||||
* Submits a PENDING request — manager must approve before balances are credited.
|
* Collection: N DR [Savings/CA] sources → 1 CR [GL] target.
|
||||||
*/
|
*/
|
||||||
private submitDisbursementDoubleEntry(): void {
|
private submitDisbursementDoubleEntry(): void {
|
||||||
const drRow = this.rows.find((r) => r.dbCr === 'Dr');
|
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||||
|
|
||||||
if (!drRow) {
|
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
|
||||||
this.showAlert('error', 'DR Row Missing', ['One DR row (GL source) is required.']);
|
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
|
||||||
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;
|
// Validate each row has account, amount, and payment type (skip paymentTypeId for GL rows)
|
||||||
if (!drAmount || drAmount <= 0) {
|
for (const r of [
|
||||||
this.showAlert('error', 'Amount Required', ['Please enter the amount in the DR (GL) row.']);
|
...drRows,
|
||||||
return;
|
...crRows
|
||||||
}
|
]) {
|
||||||
|
if (!r.acNo?.trim()) {
|
||||||
// Validate all CR rows have amounts and accounts
|
this.showAlert('error', 'Account Required', ['Please fill account number in all rows.']);
|
||||||
for (const cr of crRows) {
|
|
||||||
if (!cr.acNo?.trim()) {
|
|
||||||
this.showAlert('error', 'Account Required', ['Please fill account number in all CR rows.']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cr.amount || cr.amount <= 0) {
|
if (!r.amount || r.amount <= 0) {
|
||||||
this.showAlert('error', 'Amount Required', [`Please enter amount for CR row: ${cr.acNo}`]);
|
this.showAlert('error', 'Amount Required', [`Please enter amount for: ${r.acNo}`]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cr.paymentTypeId) {
|
if (!r.paymentTypeId && r.acTyp !== 'GL') {
|
||||||
this.showAlert('error', 'TXN TP Required', [`Please select payment type for CR row: ${cr.acNo}`]);
|
this.showAlert('error', 'TXN TP Required', [`Please select payment type for: ${r.acNo}`]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total CR must equal DR
|
// Verify all accounts have been loaded (accountId required for approval)
|
||||||
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
const unloaded = [
|
||||||
if (Math.abs(drAmount - totalCr) > 0.005) {
|
...drRows,
|
||||||
this.showAlert('error', 'Unbalanced Entry', [
|
...crRows
|
||||||
`DR ₹${drAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
].filter((r) => !this.rowAccountIds[r.acNo]);
|
||||||
'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) {
|
if (unloaded.length > 0) {
|
||||||
this.showAlert('error', 'Account Not Loaded', [
|
this.showAlert('error', 'Account Not Loaded', [
|
||||||
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`,
|
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`,
|
||||||
'Click on each CR row and wait for account details to load.'
|
'Click on each row and wait for account details to load.'
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||||
|
|
||||||
|
// Build entries and main GL fields based on mode
|
||||||
|
let glAccountId: number | null = null;
|
||||||
|
let glAccountNo: string | null = null;
|
||||||
|
let glAccountName: string | null = null;
|
||||||
|
let entries: any[];
|
||||||
|
|
||||||
|
if (isDisbursementMode) {
|
||||||
|
const drRow = drRows[0];
|
||||||
|
glAccountId = this.rowAccountIds[drRow.acNo] ?? null;
|
||||||
|
glAccountNo = drRow.acNo;
|
||||||
|
glAccountName = this.rowAccountNames[drRow.acNo] ?? drRow.acNo;
|
||||||
|
entries = crRows.map((cr) => ({
|
||||||
|
dbCr: 'Cr',
|
||||||
|
accountNo: cr.acNo,
|
||||||
|
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
|
||||||
|
accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS',
|
||||||
|
savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
|
||||||
|
glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
|
||||||
|
amount: String(cr.amount),
|
||||||
|
paymentTypeId: cr.paymentTypeId,
|
||||||
|
description: cr.description ?? null
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Collection: store target GL in main fields; include all rows with dbCr field
|
||||||
|
const crGl = crRows[0];
|
||||||
|
glAccountId = this.rowAccountIds[crGl.acNo] ?? null;
|
||||||
|
glAccountNo = crGl.acNo;
|
||||||
|
glAccountName = this.rowAccountNames[crGl.acNo] ?? crGl.acNo;
|
||||||
|
entries = [
|
||||||
|
...drRows,
|
||||||
|
...crRows
|
||||||
|
].map((r) => ({
|
||||||
|
dbCr: r.dbCr,
|
||||||
|
accountNo: r.acNo,
|
||||||
|
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
|
||||||
|
accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS',
|
||||||
|
savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
|
||||||
|
glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
|
||||||
|
amount: String(r.amount),
|
||||||
|
paymentTypeId: r.paymentTypeId,
|
||||||
|
description: r.description ?? null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRow = drRows[0] ?? crRows[0];
|
||||||
const payload = {
|
const payload = {
|
||||||
glAccountId: this.rowAccountIds[drRow.acNo] ?? null,
|
glAccountId,
|
||||||
glAccountNo: drRow.acNo,
|
glAccountNo,
|
||||||
glAccountName: this.rowAccountNames[drRow.acNo] ?? drRow.acNo,
|
glAccountName,
|
||||||
officeId: drRow.br ?? 1,
|
officeId: firstRow?.br ?? 1,
|
||||||
currencyCode: 'INR',
|
currencyCode: 'INR',
|
||||||
paymentTypeId: drRow.paymentTypeId ?? crRows[0]?.paymentTypeId,
|
paymentTypeId: firstRow?.paymentTypeId,
|
||||||
paymentTypeName:
|
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
|
||||||
this.paymentTypes.find((p: any) => p.id === (drRow.paymentTypeId ?? crRows[0]?.paymentTypeId))?.name ?? null,
|
description: firstRow?.description || null,
|
||||||
description: drRow.description || crRows[0]?.description || null,
|
creditEntries: JSON.stringify(entries)
|
||||||
creditEntries: JSON.stringify(creditEntries)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
@@ -1233,7 +1249,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const msg =
|
const msg =
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
err?.error?.message ??
|
err?.error?.message ??
|
||||||
'Disbursement request submission failed. Please try again.';
|
'Request submission failed. Please try again.';
|
||||||
this.showAlert('error', 'Submission Failed', [msg]);
|
this.showAlert('error', 'Submission Failed', [msg]);
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
return of(null);
|
return of(null);
|
||||||
@@ -1242,10 +1258,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((res: any) => {
|
.subscribe((res: any) => {
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
if (res) {
|
if (res) {
|
||||||
this.showAlert('success', 'Disbursement Request Submitted', [
|
const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement';
|
||||||
`Voucher No: ${res.voucherNo} · Total: ₹${drAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
const summaryLine = isCollectionMode
|
||||||
`DR: GL — ${drRow.acNo} → ${crRows.length} CR account(s)`,
|
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
|
||||||
'Pending manager approval. Balance will be credited after approval.'
|
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
|
||||||
|
this.showAlert('success', `${modeLabel} Request Submitted`, [
|
||||||
|
`Voucher No: ${res.voucherNo} · Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||||
|
summaryLine,
|
||||||
|
'Pending manager approval.'
|
||||||
]);
|
]);
|
||||||
this.rows = [];
|
this.rows = [];
|
||||||
this.rowAccountIds = {};
|
this.rowAccountIds = {};
|
||||||
@@ -2197,6 +2217,73 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSave(): void {
|
onSave(): void {
|
||||||
|
// 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');
|
||||||
|
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||||
|
|
||||||
|
if (drRows.length === 0) {
|
||||||
|
this.showAlert('error', 'DR Row Missing', [
|
||||||
|
'One DR row (source GL account) is required.',
|
||||||
|
'Select DR for the source account row.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (crRows.length === 0) {
|
||||||
|
this.showAlert('error', 'CR Row Missing', ['At least one CR row (destination account) is required.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect mode
|
||||||
|
// Disbursement: exactly 1 DR [GL] + N CR accounts
|
||||||
|
// Collection: N DR [Savings/CA] + exactly 1 CR [GL]
|
||||||
|
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
|
||||||
|
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
|
||||||
|
|
||||||
|
if (!isCollectionMode && !isDisbursementMode) {
|
||||||
|
this.showAlert('error', 'Invalid Entry Structure', [
|
||||||
|
'Disbursement: 1 DR [GL] source + N CR accounts.',
|
||||||
|
'Collection: N DR [Savings/CA] + 1 CR [GL] target.',
|
||||||
|
`Found: ${drRows.length} DR row(s) — verify account types.`
|
||||||
|
]);
|
||||||
|
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 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||||
|
'All CR amounts must sum to total DR amount.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isCollectionMode ? 'Confirm Collection Request' : 'Confirm Disbursement Request';
|
||||||
|
const confirmLines = isCollectionMode ? [
|
||||||
|
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
|
||||||
|
...drRows.map(
|
||||||
|
(r) =>
|
||||||
|
` [${r.acTyp}] ${r.acNo || '—'} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||||||
|
),
|
||||||
|
`CR [${crRows[0].acTyp}] ${crRows[0].acNo || '—'} — ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||||
|
'Request will be PENDING until manager approves.'
|
||||||
|
] : [
|
||||||
|
`DR [${drRows[0].acTyp}] ${drRows[0].acNo || '—'} — ₹${totalDr.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 })}`
|
||||||
|
),
|
||||||
|
'Request will be PENDING until manager approves.'
|
||||||
|
];
|
||||||
|
this.showConfirmAlert('info', title, confirmLines, 'Submit for Approval', () =>
|
||||||
|
this.submitDisbursementDoubleEntry()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const row = this.selectedRow;
|
const row = this.selectedRow;
|
||||||
if (!row || !row.acNo.trim() || !this.accountDetails) return;
|
if (!row || !row.acNo.trim() || !this.accountDetails) return;
|
||||||
|
|
||||||
@@ -2218,12 +2305,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
const isDeposit = row.dbCr === 'Cr';
|
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
|
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
||||||
if (this.isClerk && isDeposit && isCash) {
|
if (this.isClerk && isDeposit && isCash) {
|
||||||
this.showAlert('error', 'Cash Deposit Not Permitted', [
|
this.showAlert('error', 'Cash Deposit Not Permitted', [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 2v20M2 12h20" />
|
<path d="M12 2v20M2 12h20" />
|
||||||
</svg>
|
</svg>
|
||||||
Disbursement Review
|
{{ isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
|
||||||
</button>
|
</button>
|
||||||
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
|
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -63,11 +63,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GL Debit row -->
|
<!-- GL card — Source (disbursement) or Target (collection) -->
|
||||||
<div class="dri-card">
|
<div class="dri-card">
|
||||||
<div class="dri-section-title">
|
<div class="dri-section-title">
|
||||||
<mat-icon>account_balance</mat-icon>
|
<mat-icon>account_balance</mat-icon>
|
||||||
GL Debit (Source)
|
{{ isCollectionMode ? 'GL Credit (Target)' : 'GL Debit (Source)' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-grid">
|
<div class="dri-detail-grid">
|
||||||
<div class="dri-detail-item">
|
<div class="dri-detail-item">
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<span class="dri-detail-value">{{ request.gl_account_name }}</span>
|
<span class="dri-detail-value">{{ request.gl_account_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-item">
|
<div class="dri-detail-item">
|
||||||
<span class="dri-detail-label">DR Amount</span>
|
<span class="dri-detail-label">{{ isCollectionMode ? 'CR Amount' : 'DR Amount' }}</span>
|
||||||
<span class="dri-detail-value dri-amount">₹{{ request.dr_amount | number: '1.2-2' }}</span>
|
<span class="dri-detail-value dri-amount">₹{{ request.dr_amount | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-item">
|
<div class="dri-detail-item">
|
||||||
@@ -95,11 +95,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Credit entries -->
|
<!-- Entries table — DR savings (collection) or CR accounts (disbursement) -->
|
||||||
<div class="dri-card">
|
<div class="dri-card">
|
||||||
<div class="dri-section-title">
|
<div class="dri-section-title">
|
||||||
<mat-icon>account_balance_wallet</mat-icon>
|
<mat-icon>account_balance_wallet</mat-icon>
|
||||||
Credit Entries ({{ creditEntries.length }} account{{ creditEntries.length !== 1 ? 's' : '' }})
|
@if (isCollectionMode) {
|
||||||
|
Debit Entries ({{ drEntries.length }} account{{ drEntries.length !== 1 ? 's' : '' }} — collect from)
|
||||||
|
} @else {
|
||||||
|
Credit Entries ({{ crEntries.length }} account{{ crEntries.length !== 1 ? 's' : '' }})
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<table class="dri-cr-table">
|
<table class="dri-cr-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -108,11 +112,11 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th class="num">Amount (CR)</th>
|
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (entry of creditEntries; track entry.accountId; let i = $index) {
|
@for (entry of isCollectionMode ? drEntries : crEntries; track entry.accountNo; let i = $index) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="dri-cr-idx">{{ i + 1 }}</td>
|
<td class="dri-cr-idx">{{ i + 1 }}</td>
|
||||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||||
@@ -124,7 +128,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
|
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -167,7 +171,7 @@
|
|||||||
@if (!showRejectForm) {
|
@if (!showRejectForm) {
|
||||||
<button type="button" class="dri-btn dri-btn--approve" [disabled]="acting" (click)="approve()">
|
<button type="button" class="dri-btn dri-btn--approve" [disabled]="acting" (click)="approve()">
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>check_circle</mat-icon>
|
||||||
{{ acting ? 'Processing…' : 'Approve Disbursement' }}
|
{{ acting ? 'Processing…' : isCollectionMode ? 'Approve Collection' : 'Approve Disbursement' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="dri-btn dri-btn--reject" [disabled]="acting" (click)="showRejectForm = true">
|
<button type="button" class="dri-btn dri-btn--reject" [disabled]="acting" (click)="showRejectForm = true">
|
||||||
<mat-icon>cancel</mat-icon>
|
<mat-icon>cancel</mat-icon>
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import { of } from 'rxjs';
|
|||||||
import { ClerkDisbursementService } from '../../cashier/clerk-disbursement.service';
|
import { ClerkDisbursementService } from '../../cashier/clerk-disbursement.service';
|
||||||
|
|
||||||
interface CreditEntry {
|
interface CreditEntry {
|
||||||
accountId: number;
|
accountId?: number;
|
||||||
accountNo: string;
|
accountNo: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
accountType: string;
|
accountType: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
dbCr?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -69,8 +70,9 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (res?.credit_entries) {
|
if (res?.credit_entries) {
|
||||||
try {
|
try {
|
||||||
this.creditEntries =
|
const raw: any[] =
|
||||||
typeof res.credit_entries === 'string' ? JSON.parse(res.credit_entries) : res.credit_entries;
|
typeof res.credit_entries === 'string' ? JSON.parse(res.credit_entries) : res.credit_entries;
|
||||||
|
this.creditEntries = raw.map((e) => ({ ...e, amount: parseFloat(e.amount) || 0 }));
|
||||||
} catch {
|
} catch {
|
||||||
this.creditEntries = [];
|
this.creditEntries = [];
|
||||||
}
|
}
|
||||||
@@ -112,8 +114,21 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isCollectionMode(): boolean {
|
||||||
|
return this.creditEntries.some((e) => e.dbCr === 'Dr');
|
||||||
|
}
|
||||||
|
|
||||||
|
get drEntries(): CreditEntry[] {
|
||||||
|
return this.creditEntries.filter((e) => e.dbCr === 'Dr');
|
||||||
|
}
|
||||||
|
|
||||||
|
get crEntries(): CreditEntry[] {
|
||||||
|
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
|
||||||
|
}
|
||||||
|
|
||||||
totalCreditAmount(): number {
|
totalCreditAmount(): number {
|
||||||
return this.creditEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
|
||||||
|
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
statusClass(status: string): string {
|
statusClass(status: string): string {
|
||||||
|
|||||||
@@ -127,7 +127,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="num mdr-amount">₹{{ r.dr_amount | number: '1.2-2' }}</td>
|
<td class="num mdr-amount">₹{{ r.dr_amount | number: '1.2-2' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mdr-credit-badge">{{ creditCount(r) }} account{{ creditCount(r) !== 1 ? 's' : '' }}</span>
|
<button type="button" class="mdr-credit-badge mdr-credit-badge--btn" (click)="toggleExpand(r.id)">
|
||||||
|
{{ creditCount(r) }} account{{ creditCount(r) !== 1 ? 's' : '' }}
|
||||||
|
<mat-icon class="mdr-credit-chevron" [class.mdr-credit-chevron--open]="expandedIds.has(r.id)">
|
||||||
|
expand_more
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ r.payment_type_name || 'Transfer' }}</td>
|
<td>{{ r.payment_type_name || 'Transfer' }}</td>
|
||||||
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||||
@@ -136,6 +141,46 @@
|
|||||||
<span class="mdr-status" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
<span class="mdr-status" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (expandedIds.has(r.id)) {
|
||||||
|
<tr class="mdr-expand-row">
|
||||||
|
<td colspan="8" class="mdr-expand-cell">
|
||||||
|
<div class="mdr-credit-preview">
|
||||||
|
<table class="mdr-credit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>DR/CR</th>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Account Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (ce of getCreditEntries(r); track ce.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="mdr-actype"
|
||||||
|
[style.background]="ce.dbCr === 'Dr' ? '#fee2e2' : '#dcfce7'"
|
||||||
|
[style.color]="ce.dbCr === 'Dr' ? '#991b1b' : '#166534'"
|
||||||
|
>
|
||||||
|
{{ ce.dbCr || 'CR' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ ce.accountNo || '—' }}</td>
|
||||||
|
<td>{{ ce.accountName || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="mdr-actype">{{ ce.accountType || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num mdr-ce-amount">{{ ce.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
@if (r.status === 'REJECTED' && r.rejection_reason) {
|
@if (r.status === 'REJECTED' && r.rejection_reason) {
|
||||||
<tr class="mdr-reason-row">
|
<tr class="mdr-reason-row">
|
||||||
<td colspan="8" class="mdr-reason-cell">
|
<td colspan="8" class="mdr-reason-cell">
|
||||||
|
|||||||
@@ -341,6 +341,91 @@
|
|||||||
color: #0369a1;
|
color: #0369a1;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--btn {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 2px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #bae6fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-credit-chevron {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-expand-row td {
|
||||||
|
padding: 0;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-bottom: 2px solid #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-expand-cell {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-credit-preview {
|
||||||
|
padding: 10px 24px 12px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-credit-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0369a1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
background: #e0f2fe;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-ce-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdr-actype {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdr-reason-row td {
|
.mdr-reason-row td {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export class MyDisbursementRequestsComponent implements OnInit {
|
|||||||
|
|
||||||
requests: any[] = [];
|
requests: any[] = [];
|
||||||
totalRecords = 0;
|
totalRecords = 0;
|
||||||
|
expandedIds = new Set<number>();
|
||||||
pageSize = 20;
|
pageSize = 20;
|
||||||
pageIndex = 0;
|
pageIndex = 0;
|
||||||
pageSizeOptions = [
|
pageSizeOptions = [
|
||||||
@@ -164,15 +165,27 @@ export class MyDisbursementRequestsComponent implements OnInit {
|
|||||||
this.router.navigate(['/clerk/dashboard']);
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
}
|
}
|
||||||
|
|
||||||
creditCount(req: any): number {
|
toggleExpand(id: number): void {
|
||||||
|
if (this.expandedIds.has(id)) {
|
||||||
|
this.expandedIds.delete(id);
|
||||||
|
} else {
|
||||||
|
this.expandedIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCreditEntries(req: any): any[] {
|
||||||
try {
|
try {
|
||||||
const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries;
|
const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries;
|
||||||
return Array.isArray(arr) ? arr.length : 0;
|
return Array.isArray(arr) ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creditCount(req: any): number {
|
||||||
|
return this.getCreditEntries(req).length;
|
||||||
|
}
|
||||||
|
|
||||||
statusClass(status: string): string {
|
statusClass(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PENDING: 'mdr-status--pending',
|
PENDING: 'mdr-status--pending',
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
[class.myr-row--pending]="r.status === 'PENDING'"
|
[class.myr-row--pending]="r.status === 'PENDING'"
|
||||||
[class.myr-row--approved]="r.status === 'APPROVED'"
|
[class.myr-row--approved]="r.status === 'APPROVED'"
|
||||||
[class.myr-row--rejected]="r.status === 'REJECTED'"
|
[class.myr-row--rejected]="r.status === 'REJECTED'"
|
||||||
[class.myr-row--disbursed]="r.status === 'DISBURSED'"
|
[class.myr-row--disbursed]="r.status === 'WITHDRAWN'"
|
||||||
>
|
>
|
||||||
<td class="cell-mono myr-voucher">{{ r.voucher_no || '—' }}</td>
|
<td class="cell-mono myr-voucher">{{ r.voucher_no || '—' }}</td>
|
||||||
<td class="cell-mono myr-token">{{ r.tok_no || '—' }}</td>
|
<td class="cell-mono myr-token">{{ r.tok_no || '—' }}</td>
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
<span class="myr-rejection-text" [title]="r.rejection_reason">
|
<span class="myr-rejection-text" [title]="r.rejection_reason">
|
||||||
{{ r.rejection_reason | slice: 0 : 60 }}{{ r.rejection_reason?.length > 60 ? '…' : '' }}
|
{{ r.rejection_reason | slice: 0 : 60 }}{{ r.rejection_reason?.length > 60 ? '…' : '' }}
|
||||||
</span>
|
</span>
|
||||||
} @else if (r.status === 'DISBURSED') {
|
} @else if (r.status === 'WITHDRAWN') {
|
||||||
<div class="myr-disbursed-info">
|
<div class="myr-disbursed-info">
|
||||||
<span class="myr-disbursed-cashier">{{ r.disbursed_by || '—' }}</span>
|
<span class="myr-disbursed-cashier">{{ r.disbursed_by || '—' }}</span>
|
||||||
<span class="myr-dt">{{ formatDateTime(r.disbursed_at) }}</span>
|
<span class="myr-dt">{{ formatDateTime(r.disbursed_at) }}</span>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class MyRequestsComponent implements OnInit {
|
|||||||
{ value: 'PENDING', label: 'Pending' },
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
{ value: 'APPROVED', label: 'Approved' },
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
{ value: 'REJECTED', label: 'Rejected' },
|
{ value: 'REJECTED', label: 'Rejected' },
|
||||||
{ value: 'DISBURSED', label: 'Disbursed' }
|
{ value: 'WITHDRAWN', label: 'Disbursed' }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ export class MyRequestsComponent implements OnInit {
|
|||||||
PENDING: 'myr-status--pending',
|
PENDING: 'myr-status--pending',
|
||||||
APPROVED: 'myr-status--approved',
|
APPROVED: 'myr-status--approved',
|
||||||
REJECTED: 'myr-status--rejected',
|
REJECTED: 'myr-status--rejected',
|
||||||
DISBURSED: 'myr-status--disbursed'
|
WITHDRAWN: 'myr-status--disbursed'
|
||||||
};
|
};
|
||||||
return map[status] ?? '';
|
return map[status] ?? '';
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ export class MyRequestsComponent implements OnInit {
|
|||||||
PENDING: 'Pending Approval',
|
PENDING: 'Pending Approval',
|
||||||
APPROVED: 'Approved',
|
APPROVED: 'Approved',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
DISBURSED: 'Disbursed'
|
WITHDRAWN: 'Withdrawn'
|
||||||
};
|
};
|
||||||
return map[status] ?? status;
|
return map[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -121,7 +121,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (r of requests; track r.id) {
|
@for (r of requests; track r.id) {
|
||||||
<tr class="wri-row" [class.wri-row--disbursed]="r.status === 'DISBURSED'" (click)="review(r)">
|
<tr class="wri-row" [class.wri-row--disbursed]="r.status === 'WITHDRAWN'" (click)="review(r)">
|
||||||
<td class="cell-mono wri-voucher">{{ r.voucher_no || '—' }}</td>
|
<td class="cell-mono wri-voucher">{{ r.voucher_no || '—' }}</td>
|
||||||
<td class="cell-mono wri-token">{{ r.tok_no || '—' }}</td>
|
<td class="cell-mono wri-token">{{ r.tok_no || '—' }}</td>
|
||||||
<td class="cell-mono">{{ r.account_no }}</td>
|
<td class="cell-mono">{{ r.account_no }}</td>
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<td>{{ r.clerk_name }}</td>
|
<td>{{ r.clerk_name }}</td>
|
||||||
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||||
<td class="wri-cashier-cell">
|
<td class="wri-cashier-cell">
|
||||||
@if (r.status === 'DISBURSED') {
|
@if (r.status === 'WITHDRAWN') {
|
||||||
<div class="wri-cashier-info">
|
<div class="wri-cashier-info">
|
||||||
<span class="wri-cashier-name">{{ r.disbursed_by }}</span>
|
<span class="wri-cashier-name">{{ r.disbursed_by }}</span>
|
||||||
<span class="wri-dt">{{ formatDateTime(r.disbursed_at) }}</span>
|
<span class="wri-dt">{{ formatDateTime(r.disbursed_at) }}</span>
|
||||||
|
|||||||
+3
-3
@@ -77,7 +77,7 @@ export class WithdrawalRequestsComponent implements OnInit {
|
|||||||
{ value: 'PENDING', label: 'Pending' },
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
{ value: 'APPROVED', label: 'Approved' },
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
{ value: 'REJECTED', label: 'Rejected' },
|
{ value: 'REJECTED', label: 'Rejected' },
|
||||||
{ value: 'DISBURSED', label: 'Disbursed' }
|
{ value: 'WITHDRAWN', label: 'Withdrawn' }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ export class WithdrawalRequestsComponent implements OnInit {
|
|||||||
PENDING: 'wri-status--pending',
|
PENDING: 'wri-status--pending',
|
||||||
APPROVED: 'wri-status--approved',
|
APPROVED: 'wri-status--approved',
|
||||||
REJECTED: 'wri-status--rejected',
|
REJECTED: 'wri-status--rejected',
|
||||||
DISBURSED: 'wri-status--disbursed'
|
WITHDRAWN: 'wri-status--disbursed'
|
||||||
};
|
};
|
||||||
return map[status] ?? '';
|
return map[status] ?? '';
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ export class WithdrawalRequestsComponent implements OnInit {
|
|||||||
PENDING: 'Pending',
|
PENDING: 'Pending',
|
||||||
APPROVED: 'Approved',
|
APPROVED: 'Approved',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
DISBURSED: 'Disbursed'
|
WITHDRAWN: 'Withdrawn'
|
||||||
};
|
};
|
||||||
return map[status] ?? status;
|
return map[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user