From 010adcb44ff1b915535db327a837987d359bcb14 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Tue, 9 Jun 2026 18:26:20 +0530 Subject: [PATCH] feat(cashier): enhance transaction entry TYPE/TXN TP options, auto-search, and account type configs - Replace TYPE dropdown options with Withdrawal Slip, Cheque, Disbursement, Enter Bank Transfer, DD - Move TXN TP. (payment type) into Classification row between BRANCH and AC. TP. - Add GL and LA account types; fix SHR endpoint to /accounts/share - Auto-search A/C NO. on 4+ digits with 400ms debounce - Auto-fill ECS A/C NO. from savingsAccountNumber after account load - Accept suffix/partial accountNo match from list API - Disable broken global /search fallback across all account types - Increase field widths: TYPE 160px, TXN TP. 160px, CHEQUE NO. 165px, TOK. NO. 120px Co-Authored-By: Claude Sonnet 4.6 --- .../share-collection.component.html | 81 +++++++++ .../share-collection.component.scss | 160 ++++++++++++++++++ .../share-collection.component.ts | 85 +++++++++- .../transaction-entry.component.html | 84 ++++----- .../transaction-entry.component.scss | 16 +- .../transaction-entry.component.ts | 125 ++++++++++---- 6 files changed, 462 insertions(+), 89 deletions(-) diff --git a/src/app/cashier/share-collection/share-collection.component.html b/src/app/cashier/share-collection/share-collection.component.html index 6839b15b3..a17ed89f7 100644 --- a/src/app/cashier/share-collection/share-collection.component.html +++ b/src/app/cashier/share-collection/share-collection.component.html @@ -208,6 +208,87 @@ + + @if (isCashPayment && allDenominations.length > 0) { +
+
+ payments + Denomination Entry + @if (denominationTotal > 0) { + + ₹{{ denominationTotal | number: '1.2-2' }} + {{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }} + + } + @if (denominationTotal === 0) { + ⚠ Required for Cash + } +
+
+ + + + + + + + + + @for (d of allDenominations; track d.id) { + + + + + + } + + + + + + + + +
DenominationCountAmount
+ {{ d.label }} + +
+ + + +
+
{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}
Total + ₹{{ denominationTotal | number: '1.2-2' }} +
+ @if (!denomMatchesTotal && denominationTotal > 0) { +
+ warning + Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{ + totalAmount | number: '1.2-2' + }}. +
+ } + @if (denominationTotal > 0) { +
+ +
+ } +
+
+ } +
diff --git a/src/app/cashier/share-collection/share-collection.component.scss b/src/app/cashier/share-collection/share-collection.component.scss index 440c255aa..c3a6606a2 100644 --- a/src/app/cashier/share-collection/share-collection.component.scss +++ b/src/app/cashier/share-collection/share-collection.component.scss @@ -428,6 +428,166 @@ } } +// ── Denomination Panel ──────────────────────────────────────────────────────── + +.sc-denom { + padding: 0; + overflow: hidden; +} + +.sc-denom-body { + padding: 14px 16px; +} + +.sc-denom-table { + width: 100%; + + th, + td { + padding: 7px 10px; + font-size: 13px; + } + + tfoot td { + border-top: 2px solid #e2e8f0; + background: #f8fafc; + font-size: 14px; + } +} + +.sc-denom-chip { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 10px; + border-radius: 5px; + font-size: 12px; + font-weight: 700; + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; + + &--coin { + background: #fffbeb; + color: #d97706; + border-color: #fde68a; + } +} + +.sc-denom-stepper { + display: flex; + align-items: center; + gap: 4px; + justify-content: flex-end; + + button { + width: 26px; + height: 26px; + border: 1.5px solid #e2e8f0; + border-radius: 5px; + background: #f8fafc; + color: #475569; + font-size: 16px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + + &:hover:not(:disabled) { + background: #e0f2fe; + border-color: #7dd3fc; + color: #0369a1; + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } + } + + input { + width: 62px; + height: 28px; + border: 1.5px solid #e2e8f0; + border-radius: 6px; + padding: 0 6px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + text-align: right; + outline: none; + background: #fff; + + &:focus { + border-color: #1353a0; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + } + } +} + +.sc-denom-badge { + margin-left: auto; + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 10px; + border-radius: 5px; + font-size: 12px; + font-weight: 700; + + &--match { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + } + + &--mismatch { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } +} + +.sc-denom-required { + margin-left: auto; + font-size: 12px; + font-weight: 700; + color: #d97706; +} + +.sc-denom-mismatch { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; + padding: 9px 12px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + color: #dc2626; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + flex-shrink: 0; + } +} + +.sc-denom-actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; +} + // ── Success ────────────────────────────────────────────────────────────────── .sc-success { diff --git a/src/app/cashier/share-collection/share-collection.component.ts b/src/app/cashier/share-collection/share-collection.component.ts index 5bb95bcdd..e9d2119f3 100644 --- a/src/app/cashier/share-collection/share-collection.component.ts +++ b/src/app/cashier/share-collection/share-collection.component.ts @@ -21,6 +21,13 @@ import { CashierService } from '../cashier.service'; type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success'; +interface Denomination { + id: number; + value: number; + kind: 'note' | 'coin'; + label: string; +} + interface ShareCharge { id: number; name: string; @@ -68,6 +75,11 @@ export class ShareCollectionComponent implements OnInit { requestedSharesInput = 0; pricePerShare = 0; + // ── Denomination ──────────────────────────────────────────────────────────── + notes: Denomination[] = []; + coins: Denomination[] = []; + denominationCounts: Record = {}; + get shareAmount(): number { return this.requestedSharesInput * this.pricePerShare; } @@ -84,8 +96,34 @@ export class ShareCollectionComponent implements OnInit { return this.account?.currency?.displaySymbol ?? '₹'; } + get isCashPayment(): boolean { + if (this.paymentTypeId === null) return true; + const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); + return !!pt?.isCashPayment; + } + + get allDenominations(): Denomination[] { + const desc = (a: Denomination, b: Denomination) => b.value - a.value; + return [ + ...[...this.notes].sort(desc), + ...[...this.coins].sort(desc) + ]; + } + + get denominationTotal(): number { + return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0); + } + + get denomMatchesTotal(): boolean { + return this.denominationTotal === this.totalAmount; + } + get isValid(): boolean { - return !!this.account && this.totalAmount > 0 && !!this.transactionDate; + if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false; + if (this.isCashPayment && this.allDenominations.length > 0) { + return this.denomMatchesTotal; + } + return true; } ngOnInit(): void { @@ -95,6 +133,50 @@ export class ShareCollectionComponent implements OnInit { .getPaymentTypes() .pipe(catchError(() => of([]))) .subscribe((types: any[]) => (this.paymentTypes = types || [])); + this.loadDenominations(); + } + + private loadDenominations(): void { + this.service + .getActiveDenominations() + .pipe(catchError(() => of([]))) + .subscribe((list: any[]) => { + const rows: Denomination[] = (list || []).map((d: any) => ({ + id: d.id, + value: d.denomination ?? d.value, + kind: d.type === 'coin' ? 'coin' : 'note', + label: '₹' + (d.denomination ?? d.value) + })); + const order = (a: Denomination, b: Denomination) => b.value - a.value; + this.notes = rows.filter((r) => r.kind === 'note').sort(order); + this.coins = rows.filter((r) => r.kind === 'coin').sort(order); + }); + } + + // ── Denomination helpers ────────────────────────────────────────────────── + + denomKey(d: Denomination): string { + return `${d.kind}:${d.value}`; + } + + countFor(d: Denomination): number { + return this.denominationCounts[this.denomKey(d)] || 0; + } + + subtotalFor(d: Denomination): number { + return this.countFor(d) * d.value; + } + + onDenomChange(d: Denomination, raw: any): void { + this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0)); + } + + bumpDenom(d: Denomination, delta: number): void { + this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta); + } + + clearDenominations(): void { + this.denominationCounts = {}; } searchAccount(): void { @@ -195,5 +277,6 @@ export class ShareCollectionComponent implements OnInit { this.pricePerShare = 0; this.collectionResult = null; this.saving = false; + this.clearDenominations(); } } diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 8cb3d09b3..b5f2de636 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -83,10 +83,11 @@
@@ -98,12 +99,17 @@
-
- - + + @for (pt of paymentTypes; track pt.id) { + }
@@ -127,7 +133,8 @@ Payment instrument
-
- - -
@@ -1059,10 +1052,11 @@
@@ -1074,12 +1068,16 @@
-
- - + + @for (pt of paymentTypes; track pt.id) { + }
@@ -1101,7 +1099,8 @@ @@ -1138,19 +1137,6 @@
Payment instrument
-
- - -
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 90b526356..3a7a9ab39 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -620,7 +620,7 @@ $ws-fld-bd: #c8d8eb; flex-shrink: 0; &--type { - width: 90px; + width: 160px; } &--branch { @@ -636,13 +636,13 @@ $ws-fld-bd: #c8d8eb; } &--acno { - flex: 1.6; - min-width: 150px; + flex: 1; + min-width: 110px; } &--ecsno { - flex: 1; - min-width: 110px; + flex: 1.6; + min-width: 150px; } &--dbcr { @@ -651,7 +651,7 @@ $ws-fld-bd: #c8d8eb; } &--paytype { - width: 110px; + width: 160px; } &--amt { @@ -659,7 +659,7 @@ $ws-fld-bd: #c8d8eb; } &--cheqno { - width: 100px; + width: 165px; } &--cheqdt { @@ -667,7 +667,7 @@ $ws-fld-bd: #c8d8eb; } &--tok { - width: 72px; + width: 120px; } &--desc { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index ca8cdba70..cca15d147 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -10,8 +10,8 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { catchError } from 'rxjs/operators'; -import { forkJoin, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { forkJoin, of, Subject } from 'rxjs'; import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { CashierService } from '../cashier.service'; import { AuthenticationService } from '../../core/authentication/authentication.service'; @@ -659,6 +659,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 }>(); // Joint holder expanded details (keyed by index) expandedHolder: number | null = null; @@ -710,7 +711,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 'FD', 'RD', 'SHR', - 'CC' + 'GL', + 'LA' ]; dbCrOptions = [ 'Dr', @@ -753,6 +755,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .pipe(catchError(() => of([]))) .subscribe((types: any[]) => (this.paymentTypes = types ?? [])); + this.acNoInput$ + .pipe( + debounceTime(400), + distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row) + ) + .subscribe(({ row, index, value }) => { + if (value.trim().length >= 4) { + this.selectedRow = row; + this.loadAccountDetails(value.trim(), row.acTyp); + } + }); + if (this.isClerk) { const saved = this.clerkUiState.snapshot.savedAccount; if (saved) { @@ -1022,6 +1036,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.loadAccountDetails(row.acNo.trim(), row.acTyp); } + onAcNoInput(row: TxnRow, index: number, value: string): void { + row.acNo = value; + this.acNoInput$.next({ row, index, value }); + } + onRowSelect(row: TxnRow, index: number): void { this.selectedRow = row; // Only reload if account number changed or nothing is loaded yet @@ -1055,32 +1074,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Step 1: search by accountNo on the correct endpoint for this account type this.http - .get(`${cfg.list}?accountNo=${encodeURIComponent(acNo)}&offset=0&limit=1`) + .get(`${cfg.list}?accountNo=${encodeURIComponent(acNo)}&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 : []); - // Find exact match by accountNo — prevents taking wrong first item when API - // returns all accounts as an array (e.g. recurringdepositaccounts) - const match = allItems.find((x: any) => x.accountNo === acNo) ?? allItems[0]; + // Exact match first, then suffix/partial match (backend may return by last-N-digits) + const exactMatch = allItems.find((x: any) => x.accountNo === acNo); + const partialMatch = + !exactMatch && allItems.length === 1 + ? allItems.find((x: any) => x.accountNo?.endsWith(acNo) || x.accountNo?.includes(acNo)) + : null; + const match = exactMatch ?? partialMatch ?? null; if (match?.id) { - this.fetchAllDetails(match.id, acNo, cfg, token, acTyp); + this.fetchAllDetails(match.id, match.accountNo, cfg, token, acTyp); return; } - // Fallback: global search - this.http - .get(`/search?query=${encodeURIComponent(acNo)}&resource=${cfg.search}`) - .pipe(catchError(() => of([]))) - .subscribe((found: any) => { - if (token !== this.searchToken) return; - const list: any[] = Array.isArray(found) ? found : []; - if (list.length > 0 && list[0].entityId) { - this.fetchAllDetails(list[0].entityId, acNo, cfg, token, acTyp); - } else { - this.loadingAccount = false; - this.accountError = 'Account not found for: ' + acNo; - } - }); + this.loadingAccount = false; + this.accountError = 'Account not found for: ' + acNo; }); } @@ -1093,10 +1104,52 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: number; txnBase: string; txnSearchSupported: boolean; + maxLimit: number; + skipGlobalSearch: boolean; } { const t = acTyp?.toUpperCase(); - if (t === 'CA' || t === 'CB' || t === 'CC') { - // Current account: custom /transactions/search endpoint added to backend + if (t === 'LA') { + return { + list: '/loans', + detail: '/loans', + nominee: 'LA', + search: 'loan', + siAccountType: 1, + txnBase: '/loans', + txnSearchSupported: true, + maxLimit: 1, + skipGlobalSearch: 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', + nominee: 'SHR', + search: 'share', + siAccountType: 4, + txnBase: '/accounts/share', + txnSearchSupported: false, + maxLimit: 500, + skipGlobalSearch: true + }; + } + if (t === 'GL') { + return { + list: '/glaccounts', + detail: '/glaccounts', + nominee: 'GL', + search: 'glaccount', + siAccountType: 0, + txnBase: '/glaccounts', + txnSearchSupported: false, + maxLimit: 500, + skipGlobalSearch: true + }; + } + if (t === 'CA' || t === 'CB') { return { list: '/currentaccounts', detail: '/currentaccounts', @@ -1104,11 +1157,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { search: 'currentaccount', siAccountType: 3, txnBase: '/currentaccounts', - txnSearchSupported: true + txnSearchSupported: true, + maxLimit: 1, + skipGlobalSearch: true }; } if (t === 'FD') { - // FD stored in savings table → use /savingsaccounts for transaction search return { list: '/savingsaccounts', detail: '/fixeddepositaccounts', @@ -1116,11 +1170,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { search: 'fixeddeposit', siAccountType: 2, txnBase: '/savingsaccounts', - txnSearchSupported: true + txnSearchSupported: true, + maxLimit: 1, + skipGlobalSearch: true }; } if (t === 'RD') { - // RD stored in savings table → use /savingsaccounts for transaction search return { list: '/savingsaccounts', detail: '/recurringdepositaccounts', @@ -1128,10 +1183,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { search: 'recurringdeposit', siAccountType: 2, txnBase: '/savingsaccounts', - txnSearchSupported: true + txnSearchSupported: true, + maxLimit: 1, + skipGlobalSearch: true }; } - // SD — savings with full search support + // SD — savings return { list: '/savingsaccounts', detail: '/savingsaccounts', @@ -1139,7 +1196,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { search: 'saving', siAccountType: 2, txnBase: '/savingsaccounts', - txnSearchSupported: true + txnSearchSupported: true, + maxLimit: 1, + skipGlobalSearch: true }; } @@ -1182,6 +1241,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } this.accountDetails = acc; this.jointHolders = acc.jointHolders ?? []; + // Auto-fill ECS A/C NO. with savings account number (SHR) or account number + if (this.selectedRow) { + this.selectedRow.ecsAcNo = acc.savingsAccountNumber ?? acc.accountNo ?? ''; + } if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp); // Compute same-day compliance indicators from included transactions this.computeSameDayNeftRtgs(acc.transactions);