From 8e64f82c0ae1e1ae203cf7f54416e5447ed88658 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Mon, 15 Jun 2026 12:51:21 +0530 Subject: [PATCH] feat(cashier): parallelize account fetch, add CA maker-checker deposit and withdrawal routing - Optimize loadAccountDetails: extract clientId from search result and pass as hint - fetchAllDetails fast path: parallel forkJoin for account+client calls when clientId known - openConfirmDialog/confirmSubmit: extend deposit maker-checker to CA and CB account types - buildDepositPayload: send accountType CURRENT for CA/CB accounts Co-Authored-By: Claude Sonnet 4.6 --- .../transaction-entry.component.ts | 274 +++++++++--------- 1 file changed, 141 insertions(+), 133 deletions(-) diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 4a21d1193..12e0fd4ad 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1193,7 +1193,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { : null; const match = exactMatch ?? partialMatch ?? null; if (match?.id) { - this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp); + const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; + this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch); return; } this.loadingAccount = false; @@ -1359,142 +1360,143 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { detailParams?: string; }, token: number, - acTyp: string = '' + acTyp: string = '', + clientIdHint: string | null = null ): void { + const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`; + const todayStr = (() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + })(); + + // Shared: apply account-level fields after fetch + const applyAccountData = (acc: any): boolean => { + if (!acc) { + this.loadingAccount = false; + this.accountError = 'Could not load account details.'; + return false; + } + 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 false; + } + this.accountDetails = acc; + this.jointHolders = acc.jointHolders ?? []; + if (this.selectedRow) { + this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? ''; + } + if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp); + this.computeSameDayNeftRtgs(acc.transactions); + this.computeSameDayChequeClearing(acc.transactions); + return true; + }; + + // Shared: apply client-level fields after fetch + const applyClientData = ( + clientId: string, + r: { + client: any; + kyc: any; + nominees: any; + addresses: any; + family: any; + photo: any; + documents: any; + notes: any; + linkedAcc: any; + si: any; + todayTxns: any; + } + ): void => { + this.loadingAccount = false; + this.clientDetails = { ...r.client, familyMembers: Array.isArray(r.family) ? r.family : [] }; + const kycArr: any[] = Array.isArray(r.kyc) ? r.kyc : r.kyc ? [r.kyc] : []; + this.kycDetails = kycArr[0] ?? null; + this.nominees = Array.isArray(r.nominees) ? r.nominees : []; + this.nonPersonDetails = r.client?.clientNonPersonDetails ?? null; + this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : []; + this.clientPhotoUrl = r.photo ?? null; + const docs: any[] = Array.isArray(r.documents) ? r.documents : []; + const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')); + if (sigDoc) { + this.http + .get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }) + .pipe(catchError(() => of(null))) + .subscribe((blob: Blob | null) => { + if (blob) { + this.signatureUrl = URL.createObjectURL(blob); + this.persistClerkState(); + } + }); + } + this.accountNotes = Array.isArray(r.notes) ? r.notes : []; + this.linkedAccounts = r.linkedAcc; + this.standingInstructions = r.si?.pageItems ?? (Array.isArray(r.si) ? r.si : []); + this.todayTransactions = + r.todayTxns?.pageItems ?? r.todayTxns?.content ?? (Array.isArray(r.todayTxns) ? r.todayTxns : []); + this.persistClerkState(); + }; + + // Build all client-side observable calls (reused in both paths) + const buildClientCalls = (clientId: string) => ({ + client: this.http + .get(`/clients/${clientId}?associations=clientNonPersonDetails`) + .pipe(catchError(() => of(null))), + kyc: this.http.get(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))), + nominees: this.http + .get(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${cfg.nominee}`) + .pipe(catchError(() => of([]))), + addresses: this.http.get(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))), + family: this.http.get(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))), + photo: this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))), + documents: this.http.get(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))), + notes: this.http.get(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))), + linkedAcc: this.http.get(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))), + si: this.http + .get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) + .pipe(catchError(() => of({ pageItems: [] }))), + todayTxns: cfg.txnSearchSupported + ? this.http + .get( + `${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20` + ) + .pipe(catchError(() => of({ pageItems: [] }))) + : this.http + .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) + .pipe(catchError(() => of({ pageItems: [] }))) + }); + + if (clientIdHint) { + // FAST PATH: account detail + all client calls fire simultaneously (1 round-trip saved) + forkJoin({ + account: this.http.get(accountUrl).pipe(catchError(() => of(null))), + ...buildClientCalls(clientIdHint) + }).subscribe((result: any) => { + if (token !== this.searchToken) return; + const { account, ...clientData } = result; + if (!applyAccountData(account)) return; + applyClientData(clientIdHint, clientData); + }); + return; + } + + // SEQUENTIAL PATH: for GL accounts (no clientId in search result) this.http - .get(`${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`) + .get(accountUrl) .pipe(catchError(() => of(null))) .subscribe((acc: any) => { - if (token !== this.searchToken) return; // stale response — newer search active - if (!acc) { - this.loadingAccount = false; - this.accountError = 'Could not load account details.'; - return; - } - // 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.: GL → glCode, SHR → savingsAccountNumber, others → accountNo - if (this.selectedRow) { - 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 - this.computeSameDayNeftRtgs(acc.transactions); - this.computeSameDayChequeClearing(acc.transactions); - + if (token !== this.searchToken) return; + if (!applyAccountData(acc)) return; const clientId = acc.clientId?.toString(); if (!clientId) { this.loadingAccount = false; return; } - - const acType = cfg.nominee; - - // Today's date for transaction filter - const todayStr = (() => { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - })(); - - // Parallel: all cashier-relevant data - forkJoin({ - client: this.http - .get(`/clients/${clientId}?associations=clientNonPersonDetails`) - .pipe(catchError(() => of(null))), - kyc: this.http.get(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))), - nominees: this.http - .get(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${acType}`) - .pipe(catchError(() => of([]))), - addresses: this.http.get(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))), - family: this.http.get(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))), - photo: this.http - .get(`/clients/${clientId}/images`, { responseType: 'text' }) - .pipe(catchError(() => of(null))), - documents: this.http.get(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))), - notes: this.http.get(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))), - linkedAcc: this.http.get(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))), - si: this.http - .get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) - .pipe(catchError(() => of({ pageItems: [] }))), - nonPerson: of(null), // loaded from client associations - todayTxns: cfg.txnSearchSupported - ? this.http - .get( - `${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20` - ) - .pipe(catchError(() => of({ pageItems: [] }))) - : this.http - .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) - .pipe(catchError(() => of({ pageItems: [] }))) - }).subscribe( - ({ - client, - kyc, - nominees, - addresses, - family, - photo, - documents, - notes, - linkedAcc, - si, - nonPerson, - todayTxns - }) => { - this.loadingAccount = false; - this.clientDetails = { ...client, familyMembers: Array.isArray(family) ? family : [] }; - const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : []; - this.kycDetails = kycArr[0] ?? null; - this.nominees = Array.isArray(nominees) ? nominees : []; - this.nonPersonDetails = client?.clientNonPersonDetails ?? null; - this.clientAddresses = Array.isArray(addresses) ? addresses : []; - - // Photo - this.clientPhotoUrl = photo ?? null; - - // Signature — fetch as blob via HttpClient (which adds auth headers) - const docs: any[] = Array.isArray(documents) ? documents : []; - const sigDoc = docs.find( - (d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature') - ); - if (sigDoc) { - this.http - .get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }) - .pipe(catchError(() => of(null))) - .subscribe((blob: Blob | null) => { - if (blob) { - const url = URL.createObjectURL(blob); - this.signatureUrl = url; - // Signature arrives after the main load — refresh shared state so embedded views get it - this.persistClerkState(); - } - }); - } - - // Notes/alerts — /savings/{id}/notes returns array directly - this.accountNotes = Array.isArray(notes) ? notes : []; - - // Linked accounts - this.linkedAccounts = linkedAcc; - - // Standing Instructions - this.standingInstructions = si?.pageItems ?? (Array.isArray(si) ? si : []); - - // Today's transactions — savings uses "content", CA uses "pageItems" - this.todayTransactions = - todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []); - - // Push full state so embedded facility/photo/SI/activity views (e.g. manager review) can read it - this.persistClerkState(); - } - ); + forkJoin(buildClientCalls(clientId)).subscribe((r: any) => { + applyClientData(clientId, r); + }); }); } @@ -2279,8 +2281,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void { // Token-based cashier disbursement: balance was already deducted at manager approval const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest; - // Cashier savings deposit goes via maker-checker: balance credits only after manager approval - const isDepositRequest = isDeposit && !this.isClerk && row.acTyp === 'SD'; + // Cashier deposit (savings/current) goes via maker-checker: balance credits only after manager approval + const isDepositRequest = + isDeposit && !this.isClerk && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB'); const balanceAfterTxn = isDisbursement || isDepositRequest ? this.currentBalance // no immediate balance change @@ -2332,9 +2335,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - // Cashier savings deposit → route through maker-checker (create PENDING request, no direct credit) + // Cashier deposit (savings/current) → route through maker-checker (create PENDING request, no direct credit) // Use the dialog-confirmed amount — for cash that is the denomination total - if (!this.isClerk && !this.reviewMode && isDeposit && row.acTyp === 'SD') { + if ( + !this.isClerk && + !this.reviewMode && + isDeposit && + (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB') + ) { this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount); return; } @@ -2694,7 +2702,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return { accountNo: this.accountDetails.accountNo, - accountType: 'SAVINGS', + accountType: row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', savingsAccountId: this.accountDetails.id, clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null, clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,