feat(cashier): add GL denomination tracking, real-time balance, and enhanced closing report

- Save denomination breakdown for GL journal entries via new /cashier/gl-denomination endpoint
- Add payment type, date/time, teller name columns to GL Entries closing report
- Show GL badge for GL vouchers vs numeric ID for savings transactions
- Add payment type required and denomination overdraft validations for GL transactions
- Fix GL account number display in confirm dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-10 15:29:50 +05:30
co-authored by Claude Sonnet 4.6
parent 010adcb44f
commit 05cbe21542
4 changed files with 339 additions and 30 deletions
+57
View File
@@ -542,9 +542,21 @@
// GL Table
.gl-table {
.gl-th--voucher {
width: 80px;
}
.gl-th--date {
width: 100px;
}
.gl-th--pt {
width: 110px;
}
.gl-th--teller {
width: 120px;
}
.gl-th--dr {
color: #dc2626 !important;
}
@@ -571,6 +583,51 @@
color: #1e293b;
}
.gl-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
background: #dbeafe;
color: #1d4ed8;
text-transform: uppercase;
}
.gl-date {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.gl-time {
font-size: 11px;
color: #94a3b8;
margin-top: 1px;
}
.gl-pt-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
.gl-pt-none {
color: #94a3b8;
font-size: 13px;
}
.gl-teller {
font-size: 12px;
color: #475569;
}
.gl-amt--dr {
color: #dc2626;
font-weight: 700;
@@ -212,8 +212,11 @@
<thead>
<tr>
<th class="gl-th--voucher">Voucher No</th>
<th class="gl-th--date">Date</th>
<th>A/C No</th>
<th>A/C Name</th>
<th class="gl-th--pt">Payment Type</th>
<th class="gl-th--teller">Teller</th>
<th class="num gl-th--dr">DR (₹)</th>
<th class="num gl-th--cr">CR (₹)</th>
</tr>
@@ -221,9 +224,29 @@
<tbody>
@for (r of glRows; track $index) {
<tr [class.gl-row--dr]="r.dr > 0" [class.gl-row--cr]="r.cr > 0">
<td class="gl-voucher">{{ r.voucherNo }}</td>
<td class="gl-voucher">
@if (r.txnSource === 'GL') {
<span class="gl-badge">GL</span>
} @else {
{{ r.voucherNo }}
}
</td>
<td class="gl-date">
<div>{{ r.txnDate }}</div>
@if (r.txnTime) {
<div class="gl-time">{{ r.txnTime }}</div>
}
</td>
<td class="gl-acno">{{ r.acNo }}</td>
<td class="gl-acname">{{ r.acName }}</td>
<td class="gl-pt">
@if (r.paymentType) {
<span class="gl-pt-badge">{{ r.paymentType }}</span>
} @else {
<span class="gl-pt-none">—</span>
}
</td>
<td class="gl-teller">{{ r.tellerName || '—' }}</td>
<td class="num gl-amt--dr">
@if (r.dr > 0) {
₹{{ r.dr | number: '1.2-2' }}
@@ -239,7 +262,7 @@
</tbody>
<tfoot>
<tr class="gl-foot">
<td colspan="3" class="gl-foot__label">Total</td>
<td colspan="6" class="gl-foot__label">Total</td>
<td class="num gl-amt--dr gl-foot__val">₹{{ glTotalDr | number: '1.2-2' }}</td>
<td class="num gl-amt--cr gl-foot__val">₹{{ glTotalCr | number: '1.2-2' }}</td>
</tr>
@@ -115,7 +115,12 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
<select class="ws-f__ctl" [(ngModel)]="row.acTyp" (click)="$event.stopPropagation()">
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
>
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
}
@@ -156,6 +161,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="row.acTyp === 'GL'"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -560,7 +566,8 @@
</div>
<div class="te-panel__body">
<div class="te-balance-row">
<span class="te-bal-num">1.</span><span class="te-bal-label">Current Balance</span>
<span class="te-bal-num">1.</span>
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
<span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
>
@@ -685,8 +692,12 @@
</div>
}
<div class="te-acs-nameblock">
<span class="te-acs-name">{{ clientDetails?.displayName ?? accountDetails?.clientName ?? '—' }}</span>
<span class="te-acs-acno">{{ accountDetails?.accountNo }}</span>
<span class="te-acs-name">{{
accountDetails?.glCode
? (accountDetails?.name ?? '—')
: (clientDetails?.displayName ?? accountDetails?.clientName ?? '—')
}}</span>
<span class="te-acs-acno">{{ accountDetails?.glCode ?? accountDetails?.accountNo }}</span>
</div>
</div>
<div class="te-acs-sep"></div>
@@ -713,7 +724,13 @@
? 'Fixed Dep.'
: selectedRow?.acTyp === 'RD'
? 'Recurring'
: '—')
: selectedRow?.acTyp === 'GL'
? (accountDetails?.type?.value ?? 'GL Account')
: selectedRow?.acTyp === 'SHR'
? 'Share'
: selectedRow?.acTyp === 'LA'
? 'Loan'
: '—')
}}</span
>
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
@@ -1083,7 +1100,11 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl">AC. TP.</label>
<select class="ws-f__ctl" [(ngModel)]="row.acTyp">
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(change)="row.acTyp === 'GL' && (row.dbCr = 'Cr')"
>
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
}
@@ -1116,6 +1137,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="row.acTyp === 'GL'"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -660,6 +660,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Search token — cancels stale async results
private searchToken = 0;
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
cashVaultGlId: number | null = null;
// Joint holder expanded details (keyed by index)
expandedHolder: number | null = null;
@@ -767,6 +768,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
});
this.loadCashVaultGl();
if (this.isClerk) {
const saved = this.clerkUiState.snapshot.savedAccount;
if (saved) {
@@ -1041,10 +1044,27 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.acNoInput$.next({ row, index, value });
}
private loadCashVaultGl(): void {
// Dynamic: vault name > cash name > first ASSET type — no hardcoded IDs
this.http
.get('/financialactivityaccounts')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const items: any[] = Array.isArray(list) ? list : [];
const byName = (re: RegExp) => items.find((a: any) => re.test(a.financialActivityData?.name ?? ''));
const found =
byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET');
if (found?.glAccountData?.id) {
this.cashVaultGlId = found.glAccountData.id;
}
});
}
onRowSelect(row: TxnRow, index: number): void {
this.selectedRow = row;
// Only reload if account number changed or nothing is loaded yet
const alreadyLoaded = this.accountDetails && this.accountDetails.accountNo === row.acNo.trim();
const loadedId = this.accountDetails?.glCode ?? this.accountDetails?.accountNo;
const alreadyLoaded = this.accountDetails && loadedId === row.acNo.trim();
if (row.acNo.trim() && !alreadyLoaded) {
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
@@ -1072,22 +1092,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const token = ++this.searchToken; // Every new search gets a unique token
const cfg = this.accountTypeConfig(acTyp);
// Step 1: search by accountNo on the correct endpoint for this account type
// Step 1: search on the correct endpoint for this account type
this.http
.get<any>(`${cfg.list}?accountNo=${encodeURIComponent(acNo)}&offset=0&limit=${cfg.maxLimit}`)
.get<any>(
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}`
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
const exactMatch = allItems.find((x: any) => x.accountNo === acNo);
const exactMatch = allItems.find((x: any) => x[f] === acNo);
const partialMatch =
!exactMatch && allItems.length === 1
? allItems.find((x: any) => x.accountNo?.endsWith(acNo) || x.accountNo?.includes(acNo))
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
: null;
const match = exactMatch ?? partialMatch ?? null;
if (match?.id) {
this.fetchAllDetails(match.id, match.accountNo, cfg, token, acTyp);
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp);
return;
}
this.loadingAccount = false;
@@ -1106,6 +1129,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnSearchSupported: boolean;
maxLimit: number;
skipGlobalSearch: boolean;
listQueryParam: string;
matchField: string;
detailParams: string;
supportsPagination: boolean;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -1118,12 +1145,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/loans',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'SHR') {
// Backend ignores accountNo filter on list — fetch all and match client-side
// Global /search?resource=share returns 500 — skip it
return {
list: '/accounts/share',
detail: '/accounts/share',
@@ -1133,10 +1162,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/accounts/share',
txnSearchSupported: false,
maxLimit: 500,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'GL') {
// GL uses searchParam (not accountNo) and matches on glCode field
return {
list: '/glaccounts',
detail: '/glaccounts',
@@ -1146,7 +1180,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/glaccounts',
txnSearchSupported: false,
maxLimit: 500,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
supportsPagination: false
};
}
if (t === 'CA' || t === 'CB') {
@@ -1159,7 +1197,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/currentaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'FD') {
@@ -1172,7 +1214,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'RD') {
@@ -1185,7 +1231,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
// SD — savings
@@ -1198,7 +1248,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
@@ -1219,12 +1273,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
detailParams?: string;
},
token: number,
acTyp: string = ''
): void {
this.http
.get<any>(`${cfg.detail}/${accountId}?associations=all`)
.get<any>(`${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
if (token !== this.searchToken) return; // stale response — newer search active
@@ -1233,17 +1288,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.accountError = 'Could not load account details.';
return;
}
// Cross-check: returned accountNo must match what cashier searched
if (acc.accountNo && acc.accountNo !== acNo) {
// Cross-check: returned identifier must match what cashier searched
const returnedId = acc.glCode ?? acc.accountNo;
if (returnedId && returnedId !== acNo) {
this.loadingAccount = false;
this.accountError = `Account mismatch. Please re-enter account number and try again.`;
return;
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// Auto-fill ECS A/C NO. with savings account number (SHR) or account number
// Auto-fill ECS A/C NO.: GL → glCode, SHR → savingsAccountNumber, others → accountNo
if (this.selectedRow) {
this.selectedRow.ecsAcNo = acc.savingsAccountNumber ?? acc.accountNo ?? '';
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
}
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
// Compute same-day compliance indicators from included transactions
@@ -1484,7 +1540,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
return this.accountDetails?.summary?.accountBalance ?? 0;
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
}
get clearBalance(): number {
@@ -1935,6 +1991,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private proceedAfterValidation(row: TxnRow, amount: number, isDeposit: boolean, selectedPtEarly: any): void {
const isWithdrawal = !isDeposit;
const isCash = !!selectedPtEarly?.isCashPayment;
const isGlType = row.acTyp === 'GL';
// GL transactions post as journal entries — skip account-level facility/balance checks
if (isGlType) {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
return;
}
// Payment type required (same as savings)
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
}
// Denomination overdraft check for GL withdrawal (same as savings)
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
const lines = this.denominationOverdrafts.map(
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
);
this.showAlert('error', 'Insufficient Denomination Stock', [
'Your cash drawer does not have enough notes/coins:',
...lines,
'Adjust denomination or request vault replenishment.'
]);
return;
}
const AML_GL = 200000;
if (amount > AML_GL && isCash) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// 0d. Withdrawal facility
if (isWithdrawal) {
@@ -2028,7 +2133,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const balanceAfterTxn = isDeposit ? this.currentBalance + amount : this.currentBalance - amount;
this.confirmData = {
type: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
accountNo: this.accountDetails.accountNo,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
clientName: this.clientName,
amount,
currentBalance: this.currentBalance,
@@ -2049,6 +2154,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp);
// GL type → post as manual journal entry
if (row.acTyp === 'GL') {
this.submitGlJournalEntry(row, amount, isDeposit);
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
@@ -2134,6 +2245,102 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.accountDetails?.glCode ?? row.acNo,
accountName: this.accountDetails?.name ?? '',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);