From 9094b9a0eb26068901798fe79e2adb79d436e941 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Fri, 5 Jun 2026 10:59:34 +0530 Subject: [PATCH] feat(cashier): add full-screen cashier teller workstation dashboard - Add Transaction Entry screen with full account details panel (personal info, joint holders, nominees, KYC, balances) - Role-based layout: CASHIER role hides nav/sidebar/ticker, redirects to /cashier/transaction-entry on login - Denomination entry with opening/closing position preview (notes first then coins, stepper +/- round button inputs) - Auto-fetch savings/current/FD/RD accounts with associations, client KYC, nominees, addresses, family members (S/D/W/H/O) - Submit deposit/withdrawal via POST /transactions with denominationBreakdown - Cashier position auto-loads for CASHIER role (no filter dropdowns) - Pagination added to cash-history tabs and currency-exchange-detail - Cashier menu simplified for cashier role permissions Co-Authored-By: Claude Sonnet 4.6 --- .../cash-history/cash-history.component.html | 36 +- .../cash-history/cash-history.component.ts | 174 +++- .../cashier-position.component.html | 52 +- .../cashier-position.component.ts | 63 +- src/app/cashier/cashier-routing.module.ts | 10 + src/app/cashier/cashier.service.ts | 32 +- .../currency-exchange-detail.component.html | 10 + .../currency-exchange-detail.component.ts | 32 +- .../transaction-entry.component.html | 644 ++++++++++++++ .../transaction-entry.component.scss | 839 ++++++++++++++++++ .../transaction-entry.component.ts | 655 ++++++++++++++ .../authentication/authentication.service.ts | 24 + src/app/core/shell/shell.component.html | 350 +++++--- src/app/core/shell/shell.component.scss | 62 ++ src/app/core/shell/shell.component.ts | 20 +- src/app/home/home.component.ts | 5 + src/app/login/login.component.ts | 6 +- src/environments/.env.ts | 4 +- 18 files changed, 2828 insertions(+), 190 deletions(-) create mode 100644 src/app/cashier/transaction-entry/transaction-entry.component.html create mode 100644 src/app/cashier/transaction-entry/transaction-entry.component.scss create mode 100644 src/app/cashier/transaction-entry/transaction-entry.component.ts diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html index ffb27f148..4e85528dd 100644 --- a/src/app/cashier/cash-history/cash-history.component.html +++ b/src/app/cashier/cash-history/cash-history.component.html @@ -35,27 +35,55 @@ } - +
+
- +
+
- +
+
- +
+
diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts index 40f291e78..6f49e8d53 100644 --- a/src/app/cashier/cash-history/cash-history.component.ts +++ b/src/app/cashier/cash-history/cash-history.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; @@ -65,7 +66,8 @@ interface DetailView { FormsModule, MatIconModule, MatSnackBarModule, - MatTabsModule + MatTabsModule, + MatPaginatorModule ] }) export class CashHistoryComponent implements OnInit { @@ -78,6 +80,23 @@ export class CashHistoryComponent implements OnInit { exchanges: TxnRow[] = []; loading = false; + // Pagination — per tab + pageSize = 20; + pageSizeOptions = [ + 10, + 20, + 50, + 100 + ]; + transfersTotal = 0; + transfersOffset = 0; + settlementsTotal = 0; + settlementsOffset = 0; + interTotal = 0; + interOffset = 0; + exchangesTotal = 0; + exchangesOffset = 0; + private denomKindMap = new Map(); reversingId: number | null = null; @@ -96,15 +115,55 @@ export class CashHistoryComponent implements OnInit { this.load(); } + onPageChange(tab: 'transfers' | 'settlements' | 'inter' | 'exchanges', event: PageEvent): void { + this.pageSize = event.pageSize; + if (tab === 'transfers') { + this.transfersOffset = event.pageIndex * event.pageSize; + this.loadTransfers(); + } + if (tab === 'settlements') { + this.settlementsOffset = event.pageIndex * event.pageSize; + this.loadSettlements(); + } + if (tab === 'inter') { + this.interOffset = event.pageIndex * event.pageSize; + this.loadInter(); + } + if (tab === 'exchanges') { + this.exchangesOffset = event.pageIndex * event.pageSize; + this.loadExchanges(); + } + } + load(): void { + this.transfersOffset = 0; + this.settlementsOffset = 0; + this.interOffset = 0; + this.exchangesOffset = 0; this.loading = true; forkJoin({ - transfers: this.service.getCashTransfers().pipe(catchError(() => of([]))), - settlements: this.service.getCashSettlements().pipe(catchError(() => of([]))), - inter: this.service.getInterCashierTransfers().pipe(catchError(() => of([]))), - exchanges: this.service.getCashExchanges().pipe(catchError(() => of([]))) + transfers: this.service + .getCashTransfers(0, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))), + settlements: this.service + .getCashSettlements(0, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))), + inter: this.service + .getInterCashierTransfers(0, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))), + exchanges: this.service + .getCashExchanges(0, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) }).subscribe(({ transfers, settlements, inter, exchanges }) => { - this.transfers = (transfers as any[]).map((t) => ({ + this.transfersTotal = transfers.totalFilteredRecords ?? 0; + this.settlementsTotal = settlements.totalFilteredRecords ?? 0; + this.interTotal = inter.totalFilteredRecords ?? 0; + this.exchangesTotal = exchanges.totalFilteredRecords ?? 0; + const transferItems = transfers.pageItems ?? []; + const settlementItems = settlements.pageItems ?? []; + const interItems = inter.pageItems ?? []; + const exchangeItems = exchanges.pageItems ?? []; + this.transfers = (transferItems as any[]).map((t) => ({ id: t.id, date: t.transferDate, fromParty: t.fromParty, @@ -119,7 +178,7 @@ export class CashHistoryComponent implements OnInit { reverseReason: t.reverseReason ?? null, type: 'transfer' })); - this.settlements = (settlements as any[]).map((s) => ({ + this.settlements = (settlementItems as any[]).map((s) => ({ id: s.id, date: s.settlementDate, fromParty: s.fromParty, @@ -135,7 +194,7 @@ export class CashHistoryComponent implements OnInit { reverseReason: s.reverseReason ?? null, type: 'settlement' })); - this.interCashier = (inter as any[]).map((i) => ({ + this.interCashier = (interItems as any[]).map((i) => ({ id: i.id, date: i.transferDate, fromParty: i.fromParty, @@ -150,7 +209,7 @@ export class CashHistoryComponent implements OnInit { reverseReason: i.reverseReason ?? null, type: 'inter' })); - this.exchanges = (exchanges as any[]).map((e) => ({ + this.exchanges = (exchangeItems as any[]).map((e) => ({ id: e.id, date: e.exchangeDate, fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', @@ -169,6 +228,103 @@ export class CashHistoryComponent implements OnInit { }); } + private loadTransfers(): void { + this.service + .getCashTransfers(this.transfersOffset, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.transfersTotal = res.totalFilteredRecords ?? 0; + this.transfers = (res.pageItems ?? []).map((t: any) => ({ + id: t.id, + date: t.transferDate, + fromParty: t.fromParty, + toParty: t.toParty, + amount: t.transferAmount, + note: t.note, + isReversed: t.isReversed, + performedByUsername: t.performedByUsername, + createdAt: t.createdAt, + reversedBy: t.reversedBy ?? null, + reversedAt: t.reversedAt ?? null, + reverseReason: t.reverseReason ?? null, + type: 'transfer' + })); + }); + } + + private loadSettlements(): void { + this.service + .getCashSettlements(this.settlementsOffset, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.settlementsTotal = res.totalFilteredRecords ?? 0; + this.settlements = (res.pageItems ?? []).map((s: any) => ({ + id: s.id, + date: s.settlementDate, + fromParty: s.fromParty, + fromCashierId: s.fromCashierId ?? undefined, + toParty: s.toParty, + amount: s.settlementAmount, + note: s.note, + isReversed: s.isReversed, + performedByUsername: s.performedByUsername, + createdAt: s.createdAt, + reversedBy: s.reversedBy ?? null, + reversedAt: s.reversedAt ?? null, + reverseReason: s.reverseReason ?? null, + type: 'settlement' + })); + }); + } + + private loadInter(): void { + this.service + .getInterCashierTransfers(this.interOffset, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.interTotal = res.totalFilteredRecords ?? 0; + this.interCashier = (res.pageItems ?? []).map((i: any) => ({ + id: i.id, + date: i.transferDate, + fromParty: i.fromParty, + toParty: i.toParty, + amount: i.transferAmount, + note: i.note, + isReversed: i.isReversed, + performedByUsername: i.performedByUsername, + createdAt: i.createdAt, + reversedBy: i.reversedBy ?? null, + reversedAt: i.reversedAt ?? null, + reverseReason: i.reverseReason ?? null, + type: 'inter' + })); + }); + } + + private loadExchanges(): void { + this.service + .getCashExchanges(this.exchangesOffset, this.pageSize) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.exchangesTotal = res.totalFilteredRecords ?? 0; + this.exchanges = (res.pageItems ?? []).map((e: any) => ({ + id: e.id, + date: e.exchangeDate, + fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + toParty: e.performedByUsername || '—', + amount: e.exchangeAmount, + note: e.note, + isReversed: e.isReversed ?? false, + performedByUsername: e.performedByUsername, + createdAt: e.createdAt, + reversedBy: e.reversedBy ?? null, + reversedAt: e.reversedAt ?? null, + reverseReason: e.reverseReason ?? null, + type: 'exchange' + })); + }); + } + viewDetail(row: TxnRow): void { this.detail = { row, diff --git a/src/app/cashier/cashier-position/cashier-position.component.html b/src/app/cashier/cashier-position/cashier-position.component.html index 2142d986c..a7572fbf1 100644 --- a/src/app/cashier/cashier-position/cashier-position.component.html +++ b/src/app/cashier/cashier-position/cashier-position.component.html @@ -18,34 +18,44 @@
- - Teller (optional) - - All Tellers - @for (t of tellers; track t.id) { - {{ t.name }} - } - - + + @if (!isCashierMode) { + + Teller (optional) + + All Tellers + @for (t of tellers; track t.id) { + {{ t.name }} + } + + - - Cashier (optional) - - All Cashiers - @for (c of cashiers; track c.id) { - {{ c.label }} - } - - + + Cashier (optional) + + All Cashiers + @for (c of cashiers; track c.id) { + {{ c.label }} + } + + + } As of Date - + @if (isCashierMode) { + + + } @else { + + }
@if (errorMsg) { diff --git a/src/app/cashier/cashier-position/cashier-position.component.ts b/src/app/cashier/cashier-position/cashier-position.component.ts index 3f5c5ed9d..04f74efea 100644 --- a/src/app/cashier/cashier-position/cashier-position.component.ts +++ b/src/app/cashier/cashier-position/cashier-position.component.ts @@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; interface DenomRow { denominationId: number; @@ -54,6 +55,7 @@ interface CashierPos { }) export class CashierPositionComponent implements OnInit { private service = inject(CashierService); + private authService = inject(AuthenticationService); tellers: { id: number; name: string }[] = []; cashiers: { id: number; label: string }[] = []; @@ -65,15 +67,64 @@ export class CashierPositionComponent implements OnInit { results: CashierPos[] = []; errorMsg = ''; + // True when logged-in user is a regular cashier — hides teller/cashier filters + isCashierMode = false; + ngOnInit(): void { const d = new Date(); this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - this.service - .getTellers() - .pipe(catchError(() => of([] as any[]))) - .subscribe((list) => { - this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); - }); + + this.isCashierMode = this.authService.isCashierUser(); + + if (this.isCashierMode) { + // Auto-load current cashier's own position immediately + this.loadMe(); + } else { + this.service + .getTellers() + .pipe(catchError(() => of([] as any[]))) + .subscribe((list) => { + this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); + }); + } + } + + /** Load position for the currently authenticated cashier via /me endpoint. */ + loadMe(): void { + this.loading = true; + this.results = []; + this.errorMsg = ''; + forkJoin({ + position: this.service.getCashierPositionForMe(this.asOfDate).pipe(catchError(() => of(null))), + morning: this.service.getCashierMorningBalance(0, this.asOfDate).pipe(catchError(() => of(null))) + }).subscribe(({ position, morning }) => { + this.loading = false; + if (!position || !position.cashierId) { + this.errorMsg = 'No cashier record linked to your account. Contact your administrator.'; + return; + } + // Reload morning balance using the resolved cashier ID + this.service + .getCashierMorningBalance(position.cashierId, this.asOfDate) + .pipe(catchError(() => of(null))) + .subscribe((morningData: any) => { + const morningMap = new Map(); + ((morningData?.denominations || []) as any[]).forEach((d: any) => + morningMap.set(d.denominationId, d.morningCount ?? 0) + ); + const morningTotal = Number(morningData?.totalMorning ?? 0); + this.results = [ + { + ...position, + morningTotal, + denominations: ((position.denominations || []) as any[]).map((d: any) => ({ + ...d, + morningCount: morningMap.get(d.denominationId) ?? 0 + })) + } + ]; + }); + }); } onTellerSelect(tellerId: number): void { diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 367d26e37..caec5844d 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -82,6 +82,16 @@ const routes: Routes = [ path: 'share-collection', component: ShareCollectionComponent, data: { title: 'Share Collection', breadcrumb: 'Share Collection' } + }, + { + path: 'transaction-entry', + loadComponent: () => + import('./transaction-entry/transaction-entry.component').then((m) => m.TransactionEntryComponent), + data: { + title: 'Cashier Transaction Entry', + breadcrumb: 'Transaction Entry', + hideShellLayout: true + } } ] } diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index a37de9832..c6e1bb232 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -8,7 +8,8 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { map, catchError } from 'rxjs/operators'; /** HTTP service for the Cashier menu features. */ @Injectable({ providedIn: 'root' }) @@ -24,8 +25,9 @@ export class CashierService { createCashExchange(data: any): Observable { return this.http.post('/cashexchange', data); } - getCashExchanges(): Observable { - return this.http.get('/cashexchange'); + getCashExchanges(offset = 0, limit = 20): Observable { + const params = new HttpParams().set('offset', offset).set('limit', limit); + return this.http.get('/cashexchange', { params }); } getCashExchange(id: number): Observable { return this.http.get(`/cashexchange/${id}`); @@ -49,8 +51,9 @@ export class CashierService { createCashTransfer(data: any): Observable { return this.http.post('/cashtransfer', data); } - getCashTransfers(): Observable { - return this.http.get('/cashtransfer'); + getCashTransfers(offset = 0, limit = 20): Observable { + const params = new HttpParams().set('offset', offset).set('limit', limit); + return this.http.get('/cashtransfer', { params }); } getCashTransferById(id: number): Observable { return this.http.get(`/cashtransfer/${id}`); @@ -63,8 +66,9 @@ export class CashierService { createCashSettlement(data: any): Observable { return this.http.post('/cashsettlement', data); } - getCashSettlements(): Observable { - return this.http.get('/cashsettlement'); + getCashSettlements(offset = 0, limit = 20): Observable { + const params = new HttpParams().set('offset', offset).set('limit', limit); + return this.http.get('/cashsettlement', { params }); } getCashSettlementById(id: number): Observable { return this.http.get(`/cashsettlement/${id}`); @@ -77,8 +81,9 @@ export class CashierService { createInterCashierTransfer(data: any): Observable { return this.http.post('/cashintercashier', data); } - getInterCashierTransfers(): Observable { - return this.http.get('/cashintercashier'); + getInterCashierTransfers(offset = 0, limit = 20): Observable { + const params = new HttpParams().set('offset', offset).set('limit', limit); + return this.http.get('/cashintercashier', { params }); } getInterCashierById(id: number): Observable { return this.http.get(`/cashintercashier/${id}`); @@ -157,6 +162,15 @@ export class CashierService { return this.http.get('/cashreconciliation/expected/me', { params }); } + // ── Current cashier identity ── + // Returns cashierId + cashierName for the logged-in user, or null if not a cashier. + getCurrentCashierInfo(): Observable<{ cashierId: number; cashierName: string } | null> { + return this.getCashierPositionForMe().pipe( + map((pos: any) => (pos?.cashierId ? { cashierId: pos.cashierId, cashierName: pos.cashierName ?? '' } : null)), + catchError(() => of(null)) + ); + } + // ── Payment Types ── getPaymentTypes(): Observable { return this.http.get('/paymenttypes'); diff --git a/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html index 69f80ffe3..6b2e48b9e 100644 --- a/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html +++ b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html @@ -52,5 +52,15 @@ } + + + diff --git a/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts index 38eacb054..35695abac 100644 --- a/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts +++ b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { CashierService } from '../cashier.service'; /** Lists recorded cash exchanges (Currency Exchange Detail). */ @@ -21,25 +22,48 @@ import { CashierService } from '../cashier.service'; imports: [ CommonModule, RouterLink, - MatIconModule + MatIconModule, + MatPaginatorModule ] }) export class CurrencyExchangeDetailComponent implements OnInit { private service = inject(CashierService); exchanges: any[] = []; + totalRecords = 0; + pageSize = 20; + pageIndex = 0; + pageSizeOptions = [ + 10, + 20, + 50, + 100 + ]; loading = true; ngOnInit(): void { - this.service.getCashExchanges().subscribe({ - next: (list) => { - this.exchanges = list || []; + this.load(); + } + + load(): void { + this.loading = true; + this.service.getCashExchanges(this.pageIndex * this.pageSize, this.pageSize).subscribe({ + next: (res: any) => { + this.exchanges = res.pageItems ?? []; + this.totalRecords = res.totalFilteredRecords ?? 0; this.loading = false; }, error: () => { this.exchanges = []; + this.totalRecords = 0; this.loading = false; } }); } + + onPageChange(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.load(); + } } diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html new file mode 100644 index 000000000..312bf0b35 --- /dev/null +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -0,0 +1,644 @@ + + +
+ + @if (!accountDetails && !loadingAccount) { +
+ + + + + + + + + + + + + + + + + + + @for (row of rows; track $index; let i = $index) { + + + + + + + + + + + + + + + } + +
TypBr.Tr.Tp.ActypA/c No.Ecs A/c No.Db./Cr.Cheque No.AmountDescriptionTok.No.Cheq.Date
+ + + + + + + + + + + + + + + + + + + +
+ +
+ } + + + @if (loadingAccount) { +
Loading account details…
+ } + @if (accountError && !loadingAccount) { +
{{ accountError }}
+ } + + + @if (accountDetails && !loadingAccount) { +
+ +
+ +
+
+ + + + + Personal Information +
+
+
+ Branch{{ officeName }} +
+
+ A/c No.{{ accountDetails.accountNo }} +
+
+ Name{{ clientName }} +
+
+ S/D/W/H/O{{ guardianName }} +
+
+ Address{{ clientAddress }} +
+
+ Email{{ clientEmail }} +
+
+
+ Last Txn{{ lastTransactionDate }} +
+
+ Type{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }} +
+
+
+
+ Opening Dt{{ openingDate }} +
+
+ Status{{ accountDetails.status?.value ?? '—' }} +
+
+
+ CIF No.{{ cifNumber }} +
+
+
+ + +
+
+ + + + + + + Joint A/c Holders + @if (accountDetails?.modeOfOperation) { + {{ modeOfOperationLabel }} + } +
+
+ @if (jointHolders.length === 0) { + No joint holders + } + @for (jh of jointHolders; track jh.id ?? $index) { +
+ {{ jh.displayName }} + + {{ jh.role ?? (jh.isPrimary || jh.primary ? 'Primary' : 'Co-Holder') }} + + @if (jh.sharePercentage) { + {{ jh.sharePercentage }}% + } + @if (jh.signingAuthority) { + + {{ jh.signingAuthority }} + + } +
+ } +
+ +
+ + + + Nominees +
+
+ @if (nominees.length === 0) { + No nominees + } + @for (n of nominees; track n.id ?? $index) { +
+ {{ n.nomineeDisplayName ?? '—' }} + {{ n.relationshipName ?? '—' }} + @if (n.percentageShare) { + {{ n.percentageShare }}% + } + @if (n.nomineeIsMinor) { + Minor + } +
+ } +
+
+ + +
+
+ + + + + + + Other Information +
+
+
+ Phone No.{{ clientPhone }} +
+
+ Client ID{{ accountDetails.clientId }} +
+
+ PAN{{ panNumber }} +
+
+ KYC Status + {{ + kycStatus + }} +
+
+ CKYC No.{{ ckycNumber }} +
+
+ Aadhaar{{ aadhaarNumber }} +
+
+ Product + {{ + accountDetails.savingsProductName || + accountDetails.depositProductName || + accountDetails.productName || + '—' + }} +
+
+ +
+ + + + + Account Information +
+
+
+ 1.Current Balance + {{ currencySymbol }}{{ currentBalance | number: '1.2-2' }} +
+
+ 2.Today's Clg. Amount + {{ currencySymbol }}{{ todayClgAmount | number: '1.2-2' }} +
+
+ 3.Clear Balance (2-3) + {{ currencySymbol }}{{ clearBalance | number: '1.2-2' }} +
+
+ 4.Freeze Amount + {{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }} +
+
+ 5.Withdrawable Amt. + {{ currencySymbol }}{{ withdrawableAmount | number: '1.2-2' }} +
+
+
+
+ + +
+
+ Net Balance + + {{ currencySymbol }}{{ clearBalance | number: '1.2-2' }} CR + +
+
+ A/c Type + {{ + accountDetails.depositType?.value || accountDetails.accountType || selectedRow?.acTyp || '—' + }} +
+
+ Interest Rate + {{ accountDetails.nominalAnnualInterestRate ?? 0 | number: '1.2-2' }}% +
+
+ Currency + {{ accountDetails.currency?.code ?? 'INR' }} +
+
+ + +
+
+ + + + + Basic Information +
+
+
+ Opening Balance{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }} +
+
+ Cash Hold0.00 +
+
+ Transfer Hold0.00 +
+
+ Clearing Hold0.00 +
+
+ Bal. After This Trans. + {{ currencySymbol }}{{ clearBalance | number: '1.2-2' }} +
+
+
+ + +
+ +
+
+ + + + + Opening Position — {{ today }} +
+
+ @if (!openingLoaded) { + Loading… + } + @if (openingLoaded && openingDenoms.length === 0) { + No opening position found + } + @if (openingLoaded && openingDenoms.length > 0) { + + + + + + + + + + @for (d of sortedOpeningDenoms; track d.denominationId) { + @if (d.count > 0) { + + + + + + } + } + + + + + + + + +
DenominationOpening CountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.count * d.denominationValue | number: '1.2-2' }}
Total + ₹{{ openingTotal | number: '1.2-2' }} +
+ } +
+
+ + +
+
+ + + + + Denomination Entry + @if (denominationTotal > 0) { + ₹{{ denominationTotal | number: '1.2-2' }} + } +
+
+ + + + + + + + + + @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 (denominationTotal > 0) { +
+ + +
+ } +
+
+ + +
+
+ + + + Closing Position (Preview) +
+
+ @if (closingDenoms.length === 0) { + Enter denomination to see closing position + } + @if (closingDenoms.length > 0) { + + + + + + + + + + + @for (d of sortedClosingDenoms; track d.denominationValue + d.kind) { + + + + + + + } + + + + + + + + + +
DenominationOpening{{ selectedRow?.dbCr === 'Cr' ? '+Deposit' : '-Withdrawal' }}Closing
+ ₹{{ d.denominationValue }} + {{ d.openCount }} + {{ selectedRow?.dbCr === 'Cr' ? '+' : '-' }}{{ d.entered }} + + {{ d.closingCount }} +
Total₹{{ openingTotal | number: '1.2-2' }} + {{ selectedRow?.dbCr === 'Cr' ? '+' : '-' }}₹{{ denominationTotal | number: '1.2-2' }} + + ₹{{ closingTotal | number: '1.2-2' }} +
+ } +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + @for (row of rows; track $index; let i = $index) { + + + + + + + + + + + + + + + } + +
TypBr.Tr.Tp.ActypA/c No.Ecs A/c No.Db./Cr.Cheque No.AmountDescriptionTok.No.Cheq.Date
+ + + + + +
+ +
+ + +
+ + + + + + +
+
+ } +
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss new file mode 100644 index 000000000..462567adf --- /dev/null +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -0,0 +1,839 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + display: block; + min-height: 100vh; + background: #f0f2f5; + font-family: Inter, Roboto, sans-serif; + font-size: 13px; +} + +// ── Root wrapper ────────────────────────────────────────────────────────────── + +.te-root { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 14px; +} + +// ── Entry Grid Card ─────────────────────────────────────────────────────────── + +.te-grid-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 4px rgb(0 0 0 / 6%); +} + +.te-grid { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + background: #1e293b; + color: #94a3b8; + padding: 7px 8px; + text-align: left; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border-right: 1px solid #334155; + white-space: nowrap; + + &:last-child { + border-right: none; + } + } + + td { + border-right: 1px solid #f1f5f9; + border-bottom: 1px solid #f1f5f9; + padding: 3px 4px; + background: #fff; + + &:last-child { + border-right: none; + } + } + + tr.te-row--selected td { + background: #eff6ff; + } + + tr:hover td { + background: #f8fafc; + cursor: pointer; + } + + &--mini th { + font-size: 10px; + padding: 5px 6px; + background: #334155; + } +} + +.te-cell { + border: 1px solid #e2e8f0; + border-radius: 5px; + background: #f8fafc; + font-family: 'Roboto Mono', monospace; + font-size: 12px; + padding: 4px 7px; + color: #1e293b; + outline: none; + width: 100%; + transition: + border-color 0.15s, + background 0.15s; + + &:focus { + border-color: #3b82f6; + background: #fff; + box-shadow: 0 0 0 2px rgb(59 130 246 / 15%); + } + + &--xs { + width: 36px; + } + + &--sm { + width: 56px; + } + + &--md { + width: 88px; + } + + &--lg { + width: 130px; + } +} + +select.te-cell { + cursor: pointer; +} + +.te-grid-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +.te-eff-date { + font-size: 12px; + color: #64748b; + + strong { + color: #0f172a; + } +} + +.te-add-btn { + width: 26px; + height: 26px; + border-radius: 50%; + background: #0ea5e9; + color: #fff; + border: none; + font-size: 18px; + font-weight: 300; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + box-shadow: 0 2px 6px rgb(14 165 233 / 35%); + + &:hover { + background: #0284c7; + } +} + +// ── Status messages ────────────────────────────────────────────────────────── + +.te-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + color: #64748b; + font-size: 13px; + + &::before { + content: ''; + width: 16px; + height: 16px; + border: 2px solid #e2e8f0; + border-top-color: #3b82f6; + border-radius: 50%; + animation: te-spin 0.7s linear infinite; + } +} + +@keyframes te-spin { + to { + transform: rotate(360deg); + } +} + +.te-error { + padding: 10px 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #dc2626; + font-size: 13px; + font-weight: 500; +} + +// ── Main Detail Panel ───────────────────────────────────────────────────────── + +.te-detail { + display: flex; + flex-direction: column; + gap: 10px; +} + +.te-detail-top { + display: grid; + grid-template-columns: 1.1fr 0.9fr 1.1fr; + gap: 10px; + + @media (width <= 1100px) { + grid-template-columns: 1fr 1fr; + } + + @media (width <= 700px) { + grid-template-columns: 1fr; + } +} + +// ── Panel Card ──────────────────────────────────────────────────────────────── + +.te-panel { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 4px rgb(0 0 0 / 5%); + + &__header { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + background: linear-gradient(135deg, #1e293b, #334155); + color: #e2e8f0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + + &--mt { + margin-top: 0; + border-top: 1px solid #e2e8f0; + background: linear-gradient(135deg, #334155, #475569); + } + } + + &__body { + padding: 10px 14px; + + &--center { + display: flex; + align-items: center; + justify-content: center; + min-height: 60px; + } + } +} + +// ── Field rows ──────────────────────────────────────────────────────────────── + +.te-field { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 5px; + font-size: 12px; + border-bottom: 1px dotted #f1f5f9; + padding-bottom: 4px; + + &:last-child { + border-bottom: none; + margin-bottom: 0; + } +} + +.te-field-row { + display: flex; + gap: 10px; + + .te-field { + flex: 1; + } +} + +.te-label { + min-width: 88px; + color: #64748b; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + padding-top: 1px; +} + +.te-value { + color: #1e293b; + font-size: 12px; + font-weight: 500; + flex: 1; + + &-name { + font-weight: 700; + color: #0f172a; + font-size: 13px; + } + + &-wrap { + white-space: normal; + word-break: break-word; + } +} + +.te-mono { + font-family: 'Roboto Mono', 'Courier New', monospace; +} + +.te-dim { + color: #94a3b8; + font-size: 11px; +} + +// ── Joint holders / Nominees ────────────────────────────────────────────────── + +.te-joint-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid #f1f5f9; + font-size: 12px; + + &:last-child { + border-bottom: none; + } +} + +.te-joint-name { + flex: 1; + font-weight: 600; + color: #1e293b; +} + +.te-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + white-space: nowrap; + + &--primary { + background: #dbeafe; + color: #1d4ed8; + border-color: #bfdbfe; + } +} + +.te-mandate-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + background: #fef3c7; + color: #92400e; + border: 1px solid #fde68a; + margin-left: 8px; + text-transform: none; + letter-spacing: 0; +} + +.te-sign-badge { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--mandatory { + background: #fff7ed; + color: #c2410c; + border: 1px solid #fdba74; + } + + &--optional { + background: #f0fdf4; + color: #15803d; + border: 1px solid #86efac; + } +} + +// ── Balance rows ────────────────────────────────────────────────────────────── + +.te-balance-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; + border-bottom: 1px dotted #f1f5f9; + font-size: 12px; + + &:last-child { + border-bottom: none; + } + + &--total { + border-top: 1px solid #e2e8f0; + border-bottom: none; + font-weight: 700; + padding-top: 6px; + margin-top: 4px; + } +} + +.te-bal-num { + width: 18px; + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; +} + +.te-bal-label { + flex: 1; + color: #475569; + font-size: 12px; +} + +.te-bal-value { + font-weight: 700; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 100px; + color: #1e293b; + + &--cr { + color: #059669; + } + + &--dr { + color: #dc2626; + } +} + +// ── Net Balance Bar ─────────────────────────────────────────────────────────── + +.te-balance-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 4px rgb(0 0 0 / 5%); +} + +.te-bal-item { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + border-right: 1px solid #e2e8f0; + font-size: 12px; + + &:last-child { + border-right: none; + } + + &__label { + color: #64748b; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + } + + &__val { + font-weight: 700; + color: #1e293b; + font-variant-numeric: tabular-nums; + + &--cr { + color: #059669; + } + + &--big { + font-size: 18px; + } + } +} + +// ── Denomination Section ────────────────────────────────────────────────────── + +.te-denom-wrap { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + + @media (width <= 1100px) { + grid-template-columns: 1fr 1fr; + } + + @media (width <= 700px) { + grid-template-columns: 1fr; + } +} + +.te-denom-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + background: #f1f5f9; + color: #64748b; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 5px 10px; + border-bottom: 1px solid #e2e8f0; + text-align: left; + + &.num { + text-align: right; + } + } + + td { + padding: 5px 10px; + border-bottom: 1px solid #f8fafc; + color: #1e293b; + font-size: 12px; + + &.num { + text-align: right; + font-variant-numeric: tabular-nums; + } + } + + tr:last-child td { + border-bottom: none; + } + + tr:hover td { + background: #f8fafc; + } + + tfoot td { + background: #f1f5f9; + border-top: 2px solid #e2e8f0; + font-size: 12px; + padding: 6px 10px; + } +} + +// ── Section header row inside denomination table ────────────────────────────── + +.te-denom-section-row td { + padding: 4px 10px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + border-top: 1px solid #e2e8f0; + + &.te-denom-section-row__label { + color: #1d4ed8; + background: #eff6ff; + } +} + +.te-denom-section-row--coin td.te-denom-section-row__label { + color: #92400e; + background: #fffbeb; +} + +.te-denom-section-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #1d4ed8; + background: #eff6ff; + border-bottom: 1px solid #dbeafe; + border-top: 1px solid #e2e8f0; + padding: 4px 10px; + + &--coin { + color: #92400e; + background: #fffbeb; + border-color: #fde68a; + } +} + +.te-denom-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 24px; + padding: 0 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + background: linear-gradient(135deg, #ecfeff, #cffafe); + color: #155e75; + border: 1px solid #a5f3fc; + + &--coin { + border-radius: 50%; + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + background: linear-gradient(135deg, #fef9c3, #fde68a); + color: #854d0e; + border-color: #fcd34d; + } +} + +.te-denom-stepper { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + + button { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1.5px solid #e2e8f0; + background: #f8fafc; + font-size: 16px; + font-weight: 600; + color: #475569; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s; + padding: 0; + line-height: 1; + + &:hover:not(:disabled) { + background: #0ea5e9; + border-color: #0ea5e9; + color: #fff; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + + input { + width: 64px; + height: 32px; + border: 1.5px solid #e2e8f0; + border-radius: 8px; + text-align: center; + font-size: 14px; + font-weight: 700; + color: #1e293b; + background: #fff; + outline: none; + appearance: textfield; + transition: + border-color 0.15s, + box-shadow 0.15s; + + &:focus { + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgb(14 165 233 / 15%); + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + } + + &::placeholder { + color: #cbd5e1; + } + } +} + +.te-denom-actions { + display: flex; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #e2e8f0; +} + +.te-cr { + color: #059669 !important; +} + +.te-dr { + color: #dc2626 !important; +} + +.te-closing-row--neg td { + background: #fef2f2; +} + +// ── Basic Info horizontal layout ───────────────────────────────────────────── + +.te-basic-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + + .te-balance-row { + flex-direction: column; + align-items: flex-start; + border-right: 1px solid #f1f5f9; + border-bottom: none; + padding: 10px 16px; + + &:last-child { + border-right: none; + } + + &--total { + border-top: none; + padding-top: 10px; + margin-top: 0; + } + + .te-bal-label { + font-size: 11px; + color: #94a3b8; + margin-bottom: 4px; + } + + .te-bal-value { + font-size: 15px; + min-width: unset; + } + } + + @media (width <= 900px) { + grid-template-columns: repeat(3, 1fr); + } +} + +// ── Action Buttons ──────────────────────────────────────────────────────────── + +.te-actions { + display: flex; + gap: 8px; + padding: 12px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + justify-content: flex-end; + flex-wrap: wrap; + box-shadow: 0 1px 4px rgb(0 0 0 / 5%); +} + +.te-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 18px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + border: 1.5px solid #e2e8f0; + background: #fff; + color: #475569; + transition: all 0.15s; + + &:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: #1e293b; + } + + &:active { + transform: scale(0.98); + } + + &--primary { + background: linear-gradient(135deg, #0ea5e9, #0284c7); + color: #fff; + border-color: transparent; + box-shadow: 0 2px 8px rgb(14 165 233 / 30%); + + &:hover { + background: linear-gradient(135deg, #0284c7, #0369a1); + } + } + + &--exit { + background: #fff; + color: #ef4444; + border-color: #fecaca; + + &:hover { + background: #fef2f2; + } + } + + &--mode { + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + color: #fff; + border-color: transparent; + box-shadow: 0 2px 8px rgb(139 92 246 / 30%); + min-width: 130px; + + &:hover { + background: linear-gradient(135deg, #7c3aed, #6d28d9); + } + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts new file mode 100644 index 000000000..cbcdca599 --- /dev/null +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -0,0 +1,655 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, 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 { CashierService } from '../cashier.service'; + +interface TxnRow { + typ: string; + br: number; + trTp: string; + acTyp: string; + acNo: string; + ecsAcNo: string; + dbCr: string; + chequeNo: string; + amount: number | null; + description: string; + tokNo: string; + cheqDate: string; +} + +interface Denomination { + id: number; + value: number; + kind: 'note' | 'coin'; + label: string; +} + +@Component({ + selector: 'mifosx-transaction-entry', + templateUrl: './transaction-entry.component.html', + styleUrls: ['./transaction-entry.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule + ] +}) +export class TransactionEntryComponent implements OnInit { + private http = inject(HttpClient); + private cashierService = inject(CashierService); + + today = ''; + rows: TxnRow[] = []; + selectedRow: TxnRow | null = null; + loadingAccount = false; + accountError = ''; + + // Full account + client data + accountDetails: any = null; // savings account API response + clientDetails: any = null; // client personal details + kycDetails: any = null; // KYC (PAN, Aadhaar, CKYC) + nominees: any[] = []; // nominee list + jointHolders: any[] = []; // joint holders + clientAddresses: any[] = []; // client addresses + + acTypOptions = [ + 'SD', + 'CA', + 'FD', + 'RD', + 'SHR', + 'CC' + ]; + dbCrOptions = [ + 'Dr', + 'Cr' + ]; + trTpOptions = [ + 'C', + 'D', + 'T' + ]; + + // ── Denomination ────────────────────────────────────────────────────────── + notes: Denomination[] = []; + coins: Denomination[] = []; + denominationCounts: Record = {}; + + // ── Opening / Closing position ──────────────────────────────────────────── + openingDenoms: any[] = []; + openingTotal = 0; + openingLoaded = false; + + // ── Transaction saving state ────────────────────────────────────────────── + saving = false; + + ngOnInit(): void { + const d = new Date(); + this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; + this.addRow(); + this.loadDenominations(); + this.loadOpeningPosition(); + } + + private loadDenominations(): void { + this.cashierService + .getActiveDenominations() + .pipe(catchError(() => of([]))) + .subscribe((list: any[]) => { + const rows: Denomination[] = (list || []).map((d: any) => ({ + id: d.id, + value: d.denomination ?? d.value, + kind: d.kind as 'note' | 'coin', + 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); + }); + } + + private loadOpeningPosition(): void { + const d = new Date(); + const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + this.cashierService + .getCashierPositionForMe(asOfDate) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.openingDenoms = res?.denominations ?? []; + this.openingTotal = Number(res?.totalAmount ?? 0); + this.openingLoaded = true; + }); + } + + // ── 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); + } + + get notesTotal(): number { + return this.notes.reduce((s, d) => s + this.subtotalFor(d), 0); + } + get coinsTotal(): number { + return this.coins.reduce((s, d) => s + this.subtotalFor(d), 0); + } + get denominationTotal(): number { + return this.notesTotal + this.coinsTotal; + } + + /** + * Denomination Entry order: ALL notes (desc) first, then ALL coins (desc). + * This matches how cashiers physically count cash — notes first, coins after. + * Opening/Closing panels use value-based interleaving (reporting format). + */ + get allDenominations(): Denomination[] { + const desc = (a: Denomination, b: Denomination) => b.value - a.value; + return [ + ...[...this.notes].sort(desc), + ...[...this.coins].sort(desc) + ]; + } + + // ── Opening / Closing ───────────────────────────────────────────────────── + + get openingNotes(): any[] { + return this.openingDenoms.filter((d) => d.kind !== 'coin'); + } + get openingCoins(): any[] { + return this.openingDenoms.filter((d) => d.kind === 'coin'); + } + + /** Opening sorted: all notes (desc) then all coins (desc) */ + get sortedOpeningDenoms(): any[] { + const desc = (a: any, b: any) => b.denominationValue - a.denominationValue; + const notes = this.openingDenoms.filter((d) => d.kind !== 'coin').sort(desc); + const coins = this.openingDenoms.filter((d) => d.kind === 'coin').sort(desc); + return [ + ...notes, + ...coins + ]; + } + + /** Closing sorted: all notes (desc) then all coins (desc) */ + get sortedClosingDenoms(): any[] { + const desc = (a: any, b: any) => b.denominationValue - a.denominationValue; + const notes = this.closingDenoms.filter((d) => d.kind !== 'coin').sort(desc); + const coins = this.closingDenoms.filter((d) => d.kind === 'coin').sort(desc); + return [ + ...notes, + ...coins + ]; + } + + get closingTotal(): number { + const row = this.selectedRow; + const sign = row?.dbCr === 'Cr' ? 1 : -1; // Cr = Deposit (+), Dr = Withdrawal (-) + return this.openingTotal + this.denominationTotal * sign; + } + + get closingDenoms(): any[] { + const openMap = new Map(); + for (const d of this.openingDenoms) { + openMap.set(`${d.kind}:${d.denominationValue}`, d); + } + const sign = this.selectedRow?.dbCr === 'Cr' ? 1 : -1; + const result: any[] = []; + for (const denom of [ + ...this.notes, + ...this.coins + ]) { + const key = this.denomKey(denom); + const open = openMap.get(key); + const openCount = open?.count ?? 0; + const entered = this.countFor(denom); + if (openCount === 0 && entered === 0) continue; + result.push({ + denominationValue: denom.value, + kind: denom.kind, + openCount, + entered, + closingCount: openCount + entered * sign + }); + } + return result.sort((a, b) => b.denominationValue - a.denominationValue); + } + + applyDenominationTotal(): void { + if (this.selectedRow) { + this.selectedRow.amount = this.denominationTotal; + } + } + + clearDenominations(): void { + this.denominationCounts = {}; + } + + addRow(): void { + this.rows.push({ + typ: '~', + br: 1, + trTp: '', + acTyp: 'SD', + acNo: '', + ecsAcNo: '', + dbCr: '', + chequeNo: '', + amount: null, + description: '', + tokNo: '', + cheqDate: '' + }); + } + + onAcNoBlur(row: TxnRow, index: number): void { + if (!row.acNo.trim()) return; + this.selectedRow = row; + this.loadAccountDetails(row.acNo.trim(), row.acTyp); + } + + 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(); + if (row.acNo.trim() && !alreadyLoaded) { + this.loadAccountDetails(row.acNo.trim(), row.acTyp); + } + } + + private loadAccountDetails(acNo: string, acTyp: string): void { + this.loadingAccount = true; + this.accountDetails = null; + this.clientDetails = null; + this.kycDetails = null; + this.nominees = []; + this.jointHolders = []; + this.clientAddresses = []; + this.accountError = ''; + + const cfg = this.accountTypeConfig(acTyp); + + // 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`) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + const items: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); + if (items.length > 0 && items[0].id) { + this.fetchAllDetails(items[0].id, acNo, cfg); + return; + } + // Fallback: global search + this.http + .get(`/search?query=${encodeURIComponent(acNo)}&resource=${cfg.search}`) + .pipe(catchError(() => of([]))) + .subscribe((found: any) => { + const list: any[] = Array.isArray(found) ? found : []; + if (list.length > 0 && list[0].entityId) { + this.fetchAllDetails(list[0].entityId, acNo, cfg); + } else { + this.loadingAccount = false; + this.accountError = 'Account not found for: ' + acNo; + } + }); + }); + } + + /** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */ + private accountTypeConfig(acTyp: string): { list: string; detail: string; nominee: string; search: string } { + const t = acTyp?.toUpperCase(); + if (t === 'CA' || t === 'CB' || t === 'CC') { + return { list: '/currentaccounts', detail: '/currentaccounts', nominee: 'CB', search: 'currentaccount' }; + } + if (t === 'FD') { + return { list: '/fixeddepositaccounts', detail: '/fixeddepositaccounts', nominee: 'FD', search: 'fixeddeposit' }; + } + if (t === 'RD') { + return { + list: '/recurringdepositaccounts', + detail: '/recurringdepositaccounts', + nominee: 'RD', + search: 'recurringdeposit' + }; + } + // Default: SD / SB — Savings + return { list: '/savingsaccounts', detail: '/savingsaccounts', nominee: 'SB', search: 'saving' }; + } + + /** Maps UI account type code → backend accountType code for nominee filter */ + private toNomineeAccountType(acTyp: string): string { + return this.accountTypeConfig(acTyp).nominee; + } + + /** Parallel fetch: account (with associations) + client + KYC + nominees */ + private fetchAllDetails( + accountId: number, + acNo: string, + cfg: { list: string; detail: string; nominee: string; search: string } + ): void { + this.http + .get(`${cfg.detail}/${accountId}?associations=all`) + .pipe(catchError(() => of(null))) + .subscribe((acc: any) => { + if (!acc) { + this.loadingAccount = false; + this.accountError = 'Could not load account details.'; + return; + } + this.accountDetails = acc; + this.jointHolders = acc.jointHolders ?? []; + + const clientId = acc.clientId?.toString(); + if (!clientId) { + this.loadingAccount = false; + return; + } + + const acType = cfg.nominee; + + // Parallel: client + KYC + nominees + addresses + family members + forkJoin({ + client: this.http.get(`/clients/${clientId}`).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([]))) + }).subscribe(({ client, kyc, nominees, addresses, family }) => { + 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.clientAddresses = Array.isArray(addresses) ? addresses : []; + }); + }); + } + + // ── Balance getters ──────────────────────────────────────────────────────── + + get currentBalance(): number { + return this.accountDetails?.summary?.accountBalance ?? 0; + } + + get clearBalance(): number { + return this.accountDetails?.summary?.availableBalance ?? this.currentBalance; + } + + get todayClgAmount(): number { + return this.accountDetails?.summary?.totalDepositsPostMaturityLevel ?? 0; + } + + get freezeAmount(): number { + return this.accountDetails?.summary?.holdAmountBalance ?? 0; + } + + get withdrawableAmount(): number { + const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance; + return Math.max(0, avail - this.freezeAmount); + } + + get currencySymbol(): string { + return this.accountDetails?.currency?.displaySymbol ?? '₹'; + } + + get modeOfOperationLabel(): string { + const modeMap: Record = { + FORMER_OR_SURVIVOR: 'Former or Survivor', + EITHER_OR_SURVIVOR: 'Either or Survivor', + ANYONE_OR_SURVIVOR: 'Any One or Survivor', + JOINTLY: 'Both Jointly', + SINGLE: 'Single', + OTHER: 'Other' + }; + const mode = this.accountDetails?.modeOfOperation ?? ''; + return modeMap[mode] ?? mode.replace(/_/g, ' '); + } + + // ── Client detail getters ───────────────────────────────────────────────── + + get clientName(): string { + return this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? '—'; + } + + get guardianName(): string { + // S/D/W/H/O derived from family members + // API returns: firstName, middleName, lastName, relationship + const family: any[] = this.clientDetails?.familyMembers ?? []; + if (!family.length) return this.clientDetails?.guardianClientName ?? '—'; + + const priority = [ + 'FATHER', + 'MOTHER', + 'HUSBAND', + 'WIFE', + 'SPOUSE' + ]; + const prefixMap: Record = { + FATHER: 'S/O', + MOTHER: 'D/O', + HUSBAND: 'W/O', + WIFE: 'H/O', + SPOUSE: 'W/O' + }; + + for (const rel of priority) { + const member = family.find((m: any) => (m.relationship ?? m.relationshipName ?? '').toUpperCase() === rel); + if (member) { + // API uses camelCase: firstName, middleName, lastName + const name = [ + member.firstName, + member.middleName, + member.lastName + ] + .filter((v) => v && v.trim()) + .join(' ') + .trim(); + return name ? `${prefixMap[rel] ?? rel} ${name}` : (prefixMap[rel] ?? rel); + } + } + // Fallback: first family member + const m = family[0]; + const name = [ + m.firstName, + m.middleName, + m.lastName + ] + .filter((v) => v && v.trim()) + .join(' ') + .trim(); + return name || '—'; + } + + get clientAddress(): string { + // From /client/{id}/addresses — AddressData fields: addressLine1, townVillage, city, stateName, postalCode + if (this.clientAddresses.length > 0) { + const a = this.clientAddresses[0]; + return [ + a.addressLine1, + a.addressLine2, + a.addressLine3, + a.townVillage, + a.city, + a.countyDistrict, + a.stateName ?? a.stateProvinceLabel, + a.postalCode + ] + .filter((v) => v && v.toString().trim()) + .join(', '); + } + return '—'; + } + + get clientPhone(): string { + const mob = this.clientDetails?.mobileNo ?? this.accountDetails?.clientMobileNo; + const alt = this.clientDetails?.alternateMobileNo; + return ( + [ + mob, + alt + ] + .filter(Boolean) + .join(' / ') || '—' + ); + } + + get clientEmail(): string { + return this.clientDetails?.emailAddress ?? '—'; + } + + get officeName(): string { + return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—'; + } + + get openingDate(): string { + const d = this.accountDetails?.timeline?.submittedOnDate; + return d ? `${d[2]}/${d[1]}/${d[0]}` : '—'; + } + + get lastTransactionDate(): string { + const d = this.accountDetails?.lastActiveTransactionDate; + return d ? `${d[2]}/${d[1]}/${d[0]}` : '—'; + } + + get panNumber(): string { + return this.kycDetails?.panNumber ?? this.kycDetails?.pan_number ?? '—'; + } + + get aadhaarNumber(): string { + return this.kycDetails?.aadhaarNumberMasked ?? this.kycDetails?.aadhaar_number_masked ?? 'XXXX/XXXX/XXXX'; + } + + get ckycNumber(): string { + return this.kycDetails?.ckycNumber ?? this.kycDetails?.ckyc_number ?? '—'; + } + + get kycStatus(): string { + const s = this.kycDetails?.kycStatus ?? this.kycDetails?.kyc_status; + if (!s) return '—'; + const map: Record = { 100: 'Pending', 200: 'In Progress', 300: 'Verified', 400: 'Rejected' }; + return map[s] ?? `KYC-${s}`; + } + + get cifNumber(): string { + return this.clientDetails?.cifNumber ?? this.clientDetails?.cif_number ?? '—'; + } + + onSave(): void { + const row = this.selectedRow; + if (!row || !row.acNo.trim() || !this.accountDetails) return; + + const amount = row.amount ?? this.denominationTotal; + if (!amount || amount <= 0) { + alert('Please enter amount or denomination.'); + return; + } + + const isDeposit = row.dbCr === 'Cr'; + const command = isDeposit ? 'deposit' : 'withdrawal'; + const accountId = this.accountDetails.id; + const cfg = this.accountTypeConfig(row.acTyp); + + const today = new Date(); + const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`; + + const payload: any = { + transactionDate: txnDate, + transactionAmount: amount, + dateFormat: 'dd/MM/yyyy', + locale: 'en', + note: row.description || null + }; + + if (row.chequeNo) payload['checkNumber'] = row.chequeNo; + + if (this.denominationTotal > 0) { + payload['denominationBreakdown'] = [ + ...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.saving = true; + const params = new HttpParams().set('command', command); + this.http + .post(`${cfg.detail}/${accountId}/transactions`, payload, { params }) + .pipe( + catchError((err) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.developerMessage ?? 'Transaction failed.'; + alert(msg); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + alert(`Transaction posted. Ref: ${res.resourceId ?? res.transactionId ?? 'N/A'}`); + this.clearDenominations(); + this.loadOpeningPosition(); + this.loadAccountDetails(row.acNo.trim(), row.acTyp); + } + }); + } + + onCancel(): void { + this.accountDetails = null; + this.clientDetails = null; + this.kycDetails = null; + this.nominees = []; + this.jointHolders = []; + this.clientAddresses = []; + this.accountError = ''; + this.selectedRow = null; + this.rows = []; + this.addRow(); + } + + onSearch(): void { + if (this.selectedRow?.acNo.trim()) { + this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp); + } + } + + onExit(): void { + history.back(); + } +} diff --git a/src/app/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts index 5b9828cd7..73c4891d8 100644 --- a/src/app/core/authentication/authentication.service.ts +++ b/src/app/core/authentication/authentication.service.ts @@ -388,6 +388,30 @@ export class AuthenticationService { return JSON.parse(this.storage.getItem(this.credentialsStorageKey)); } + /** + * Returns true ONLY for regular cashier role (not Head Cashier). + * Regular cashier → full-screen transaction entry, no nav. + * Head Cashier → normal CBS interface, all menus visible. + */ + isCashierUser(): boolean { + const creds = this.getCredentials(); + if (!creds || !creds.permissions) return false; + // Super-admin / all-functions users are never cashier-only + if (creds.permissions.includes('ALL_FUNCTIONS')) return false; + if (creds.roles && Array.isArray(creds.roles)) { + const roleNames: string[] = creds.roles.map((r: any) => (r.name || r.role || '').toLowerCase().trim()); + // Head Cashier gets full access — NOT a cashier-only user + if (roleNames.some((n) => n.includes('head cashier') || n === 'head_cashier')) { + return false; + } + // Regular CASHIER role → cashier-only mode + if (roleNames.some((n) => n === 'cashier')) { + return true; + } + } + return false; + } + /** * Sets the user credentials. * diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 83b6e8382..a9d6ed133 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -8,7 +8,7 @@ - + - + @if (!hideShellLayout) { + + } - - - @if ((isHandset$ | async) === false) { + + + @if (!hideShellLayout && (isHandset$ | async) === false) {