+
{{ d.label }}
−
@@ -162,8 +381,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
- +
+ +
+
0"
+ [class.cx-avail--down]="netFor(d) < 0"
+ [class.cx-avail--over]="exceedsAvailable(d)"
+ >
+
+
₹{{ subtotal('g', d) | number: '1.2-2' }}
@@ -173,6 +400,30 @@
+
+ @if (impactRows.length) {
+
+
inventory_2 Drawer after this exchange
+
+ @for (r of impactRows; track r.id) {
+ 0"
+ [class.cx-impact__chip--down]="r.net < 0"
+ >
+ {{ r.label }}
+ {{ r.before }} → {{ r.after }}
+ ({{ r.net > 0 ? '+' : '' }}{{ r.net }})
+
+ }
+
+
+ The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
+ Cash Position).
+
+
+ }
+
Received ₹{{ receivedTotal | number: '1.2-2' }}
@@ -213,9 +464,15 @@
}
- @if (needsIdProof && !customerIdProof.trim()) {
+ @if (panMissing) {
- badge Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
+ badge Enter the customer's PAN to proceed (amount above ₹50,000).
+
+ }
+ @if (!customerIdentified && !loadingCustomer) {
+
+ person_search
+ {{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
}
diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss
index 9c4277912..88903f1a0 100644
--- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss
+++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss
@@ -73,99 +73,584 @@
}
}
-/* ── Customer info bar ── */
-.cx-customer-bar {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: #f8fafc;
+/* ── Customer: existing client or walk-in, plus PAN ── */
+.cx-cust {
+ background: #fff;
border: 1px solid #e2e8f0;
- border-radius: 10px;
- flex-wrap: wrap;
-}
+ border-radius: 14px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ overflow: hidden;
-.cx-customer-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 13px;
- font-weight: 600;
- color: #475569;
- cursor: pointer;
- user-select: none;
+ &__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 12px 16px;
+ background: linear-gradient(180deg, #f8fafc, #f1f5f9);
+ border-bottom: 1px solid #e2e8f0;
+ }
- mat-icon {
- font-size: 20px;
- height: 20px;
- width: 20px;
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ &__title-icon {
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: #e6f4ee;
+ color: #0f4f3c;
+
+ mat-icon {
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ }
+ }
+
+ &__title-text {
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__title-sub {
+ font-size: 12px;
+ color: #64748b;
+ }
+
+ &__modes {
+ display: inline-flex;
+ padding: 3px;
+ background: #e2e8f0;
+ border-radius: 10px;
+ }
+
+ &__mode {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ font-size: 13px;
+ font-weight: 600;
+ color: #475569;
+ cursor: pointer;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+
+ &:hover {
+ color: #0f172a;
+ }
+
+ &--active,
+ &--active:hover {
+ background: #fff;
+ color: #0f4f3c;
+ box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
+ }
+ }
+
+ &__body {
+ display: grid;
+ grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
+ gap: 16px;
+ padding: 16px;
+ }
+
+ &__who {
+ min-width: 0;
+ }
+
+ /* Nothing picked yet, or loading */
+ &__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-height: 150px;
+ padding: 20px;
+ text-align: center;
+ background: #f8fafc;
+ border: 1.5px dashed #cbd5e1;
+ border-radius: 12px;
+ }
+
+ &__empty-icon {
+ font-size: 36px;
+ width: 36px;
+ height: 36px;
color: #94a3b8;
}
- &--active {
- color: #1e40af;
+ &__empty-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #334155;
+ }
+
+ &__empty-sub {
+ margin-bottom: 8px;
+ font-size: 12px;
+ color: #64748b;
+ }
+
+ /* A picked client */
+ &__card {
+ padding: 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ }
+
+ &__card-top {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+
+ &__card-id {
+ flex: 1 1 180px;
+ min-width: 0;
+ }
+
+ &__name {
+ font-size: 16px;
+ font-weight: 700;
+ color: #0f172a;
+ overflow-wrap: anywhere;
+ }
+
+ &__badges {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 4px;
+ }
+
+ &__card-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ &__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 12px 16px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px dashed #e2e8f0;
+
+ span {
+ display: block;
+ margin-bottom: 2px;
+ font-size: 11px;
+ color: #64748b;
+ }
+
+ strong {
+ font-size: 13px;
+ font-weight: 600;
+ color: #0f172a;
+ }
+ }
+
+ /* A walk-in, identified at the counter */
+ &__walkin {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+
+ .cx-field {
+ font-size: 12px;
+ }
+
+ .cx-field input {
+ width: 100%;
+ height: 40px;
+ box-sizing: border-box;
+ }
+ }
+
+ .cx-field input:focus {
+ outline: none;
+ border-color: #1f7a4d;
+ box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
+ }
+}
+
+.cx-avatar {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #fff;
+
+ &--sm {
+ width: 40px;
+ height: 40px;
+ font-size: 14px;
+ }
+}
+
+/* The cashier at the counter, or why there is none */
+.cx-cashier {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 14px 20px;
+ padding: 12px 16px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-left: 4px solid #1f7a4d;
+ border-radius: 14px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+
+ &__id {
+ flex: 1 1 220px;
+ min-width: 0;
+ }
+
+ &__label {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #64748b;
+ }
+
+ &__name {
+ font-size: 16px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__flow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 14px;
+ margin-top: 2px;
+ font-size: 12px;
+ color: #475569;
+
+ span {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ }
mat-icon {
- color: #1e40af;
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ color: #94a3b8;
+ }
+ }
+
+ &__stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 28px;
+
+ span {
+ display: block;
+ font-size: 11px;
+ color: #64748b;
+ }
+
+ strong {
+ font-size: 14px;
+ font-weight: 600;
+ color: #0f172a;
+ }
+ }
+
+ &--none {
+ background: #fffbeb;
+ border-color: #fcd34d;
+ border-left-color: #d97706;
+
+ mat-icon {
+ color: #d97706;
+ }
+
+ .cx-cashier__label {
+ font-weight: 500;
+ letter-spacing: 0;
+ text-transform: none;
+ color: #92400e;
}
}
}
-.cx-walk-in {
- display: flex;
+.cx-kyc {
+ display: inline-flex;
align-items: center;
gap: 4px;
- font-size: 12px;
- color: #64748b;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
mat-icon {
- font-size: 16px;
- height: 16px;
- width: 16px;
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ }
+
+ &--ok {
+ background: #dcfce7;
+ color: #166534;
+ }
+
+ &--warn {
+ background: #fef3c7;
+ color: #92400e;
+ }
+
+ &--sm {
+ margin-left: 6px;
+ padding: 1px 6px;
+ font-size: 10px;
}
}
-.cx-pan-required {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 10px;
- background: #fffbeb;
- border: 1px solid #fcd34d;
+.cx-chip-tag {
+ padding: 2px 8px;
border-radius: 6px;
+ background: #eef2ff;
+ font-size: 11px;
+ font-weight: 600;
+ color: #3730a3;
+}
+
+.cx-link-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 30px;
+ padding: 0 10px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
font-size: 12px;
font-weight: 600;
- color: #92400e;
+ color: #1e40af;
+ cursor: pointer;
mat-icon {
font-size: 16px;
- height: 16px;
width: 16px;
- color: #d97706;
+ height: 16px;
+ }
+
+ &:hover {
+ background: #eff6ff;
+ }
+
+ &--danger {
+ color: #b91c1c;
+
+ &:hover {
+ background: #fef2f2;
+ }
}
}
-.cx-field--inline {
- flex-direction: row;
- align-items: center;
+/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
+.cx-pan {
+ display: flex;
+ flex-direction: column;
+ align-self: start;
gap: 8px;
+ padding: 14px;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
- span {
- white-space: nowrap;
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ mat-icon {
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ color: #475569;
+ }
}
- input {
- width: 180px;
+ &__label {
+ font-size: 13px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__tag {
+ margin-left: auto;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ font-size: 11px;
+ font-weight: 600;
+ color: #475569;
+
+ &--on {
+ background: #fef3c7;
+ color: #92400e;
+ }
+ }
+
+ &__input {
+ position: relative;
+
+ input {
+ width: 100%;
+ height: 44px;
+ box-sizing: border-box;
+ padding: 0 40px 0 12px;
+ background: #fff;
+ border: 1.5px solid #cbd5e1;
+ border-radius: 10px;
+ font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: #0f172a;
+
+ &::placeholder {
+ font-weight: 500;
+ color: #cbd5e1;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: #1f7a4d;
+ box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
+ }
+ }
+ }
+
+ &__state {
+ position: absolute;
+ top: 50%;
+ right: 10px;
+ transform: translateY(-50%);
+ font-size: 22px;
+ width: 22px;
+ height: 22px;
+
+ &--ok {
+ color: #16a34a;
+ }
+
+ &--bad {
+ color: #dc2626;
+ }
+ }
+
+ &__hint {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: #64748b;
+
+ mat-icon {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ }
+ }
+
+ &--required {
+ background: #fffbeb;
+ border-color: #fcd34d;
+
+ .cx-pan__input input {
+ border-color: #f59e0b;
+ }
+
+ .cx-pan__hint {
+ font-weight: 600;
+ color: #92400e;
+ }
+ }
+
+ &--invalid {
+ background: #fef2f2;
+ border-color: #fecaca;
+
+ .cx-pan__input input {
+ border-color: #ef4444;
+ }
+
+ .cx-pan__hint {
+ font-weight: 600;
+ color: #b91c1c;
+ }
+ }
+
+ &--valid {
+ background: #f0fdf4;
+ border-color: #bbf7d0;
+
+ .cx-pan__input input {
+ border-color: #22c55e;
+ }
+
+ .cx-pan__hint {
+ color: #166534;
+ }
}
}
-.cx-field--pan input {
- width: 160px;
- border-color: #fcd34d;
+.cx-spin {
+ animation: cx-spin 1s linear infinite;
+}
- &:focus {
- outline: none;
- border-color: #d97706;
+@keyframes cx-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (width <= 900px) {
+ .cx-cust__body {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (width <= 600px) {
+ .cx-cust__walkin {
+ grid-template-columns: 1fr;
}
}
@@ -345,6 +830,129 @@
border-bottom: 1px dotted #eef2f7;
}
+/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
+.cx-row--stock {
+ grid-template-columns: 64px 120px 1fr 96px;
+}
+
+.cx-row--over {
+ background: #fef2f2;
+}
+
+.cx-avail {
+ padding-left: 6px;
+ font-size: 12px;
+ color: #64748b;
+
+ strong {
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ /* the count after this exchange: green when it goes up, red when it goes down */
+ .cx-after {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: 6px;
+ font-weight: 600;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ }
+
+ small {
+ font-size: 11px;
+ }
+ }
+
+ &--up .cx-after,
+ &--up .cx-after strong {
+ color: #15803d;
+ }
+
+ &--down .cx-after,
+ &--down .cx-after strong {
+ color: #b45309;
+ }
+
+ &--over,
+ &--over strong,
+ &--over .cx-after,
+ &--over .cx-after strong {
+ color: #b91c1c;
+ }
+}
+
+/* The drawer after the whole exchange, one chip per denomination it changes */
+.cx-impact {
+ padding: 12px 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+ font-size: 13px;
+ font-weight: 700;
+ color: #0f172a;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #475569;
+ }
+ }
+
+ &__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ &__chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 10px;
+ border-radius: 8px;
+ background: #f1f5f9;
+ font-size: 12px;
+ color: #334155;
+
+ b {
+ font-weight: 700;
+ }
+
+ em {
+ font-style: normal;
+ font-weight: 700;
+ }
+
+ &--up {
+ background: #dcfce7;
+ color: #166534;
+ }
+
+ &--down {
+ background: #fef3c7;
+ color: #92400e;
+ }
+ }
+
+ &__note {
+ margin-top: 8px;
+ font-size: 11px;
+ color: #64748b;
+ }
+}
+
.cx-chip {
display: inline-flex;
align-items: center;
diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts
index bede34b4b..98916e2bc 100644
--- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts
+++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts
@@ -11,7 +11,15 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { forkJoin, of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
+import { ClientsService } from 'app/clients/clients.service';
+import {
+ CustomerSearchDialogComponent,
+ CustomerSearchResult
+} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
@@ -21,6 +29,47 @@ interface Denom {
label: string;
}
+/** An existing client picked through Customer Search, with what the counter needs to identify them. */
+interface SelectedCustomer {
+ clientId: number;
+ displayName: string;
+ cifNumber: string | null;
+ mobileNo: string | null;
+ officeName: string | null;
+ accountNo: string | null;
+ accountType: string | null;
+ /** PAN on the client's KYC record, if any. */
+ kycPan: string | null;
+ panVerified: boolean;
+ kycStatus: string;
+}
+
+/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
+interface CashierInfo {
+ cashierId: number;
+ name: string;
+ tellerName: string | null;
+ officeName: string | null;
+ /** Cash the vault allocated to them for the day, once known. */
+ allocatedAmount: number | null;
+ /** Cash they hold now. */
+ totalAmount: number;
+}
+
+/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
+const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
+
+/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
+const PAN_MANDATORY_ABOVE = 50000;
+
+const KYC_STATUS: Record
= {
+ 100: 'Pending',
+ 200: 'In Progress',
+ 300: 'Verified',
+ 400: 'Rejected',
+ 500: 'Expired'
+};
+
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
@@ -31,11 +80,14 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
- MatSnackBarModule
+ MatSnackBarModule,
+ MatDialogModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
+ private clientsService = inject(ClientsService);
+ private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
@@ -47,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false;
// ── Customer info ──
- isAccountHolder = false;
- accountNo = '';
- customerIdProof = '';
+ /** An existing client found through Customer Search, or a walk-in identified at the counter. */
+ customerMode: 'existing' | 'walkin' = 'walkin';
+ selectedCustomer: SelectedCustomer | null = null;
+ loadingCustomer = false;
+ walkInName = '';
+ walkInMobile = '';
+ panNumber = '';
- // ── Cashier position (for denomination overdraft check) ──
+ // ── Cashier at the counter, and their position (for the denomination overdraft check) ──
+ /** The cashier linked to the logged-in user; null when the login is not a cashier's. */
+ cashier: CashierInfo | null = null;
private availableMap = new Map();
positionLoaded = false;
@@ -88,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
});
+ const cashierId = res?.cashierId ? Number(res.cashierId) : null;
+ this.cashier = cashierId
+ ? {
+ cashierId,
+ name: res.cashierName || '',
+ tellerName: null,
+ officeName: null,
+ allocatedAmount: null,
+ totalAmount: Number(res.totalAmount ?? 0)
+ }
+ : null;
this.positionLoaded = true;
+ if (cashierId) this.loadCashierDetails(cashierId);
},
error: () => {
this.positionLoaded = true;
@@ -96,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit {
});
}
+ /** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
+ private loadCashierDetails(cashierId: number): void {
+ this.service
+ .getInterCashierCashierSummary(cashierId, this.exchangeDate)
+ .pipe(catchError(() => of(null)))
+ .subscribe((r: any) => {
+ if (!r || this.cashier?.cashierId !== cashierId) return;
+ this.cashier = {
+ ...this.cashier,
+ tellerName: r.tellerName || null,
+ officeName: r.officeName || null,
+ allocatedAmount: Number(r.allocatedAmount ?? 0)
+ };
+ });
+ }
+
+ /** Up to two letters for the cashier's avatar. */
+ get cashierInitials(): string {
+ const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return '?';
+ const first = words[0].charAt(0);
+ const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
+ return (first + last).toUpperCase();
+ }
+
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
@@ -129,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
- /** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
- get needsIdProof(): boolean {
- return this.receivedTotal >= 50000;
+ /** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
+ get needsPan(): boolean {
+ return this.receivedTotal > PAN_MANDATORY_ABOVE;
+ }
+
+ /** Up to two letters for the avatar: first and last word of the customer's name. */
+ get customerInitials(): string {
+ const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return '?';
+ const first = words[0].charAt(0);
+ const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
+ return (first + last).toUpperCase();
+ }
+
+ get panValue(): string {
+ return this.panNumber.trim().toUpperCase();
+ }
+
+ /** A PAN that is entered must be well-formed, required or not. */
+ get panFormatInvalid(): boolean {
+ return !!this.panValue && !PAN_PATTERN.test(this.panValue);
+ }
+
+ get panMissing(): boolean {
+ return this.needsPan && !this.panValue;
+ }
+
+ /** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
+ get panFromKyc(): boolean {
+ return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
+ }
+
+ /** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
+ get customerIdentified(): boolean {
+ if (this.customerMode === 'existing') return !!this.selectedCustomer;
+ return !this.needsPan || this.walkInName.trim().length > 0;
+ }
+
+ /** A login with no cashier behind it has no drawer to take notes into or give change from. */
+ get noCashier(): boolean {
+ return this.positionLoaded && !this.cashier;
+ }
+
+ /** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
+ availableFor(d: Denom): number | null {
+ if (!this.positionLoaded || !this.cashier) return null;
+ return this.availableMap.get(d.id) ?? 0;
+ }
+
+ /** The cashier is giving more of this denomination than they hold. */
+ exceedsAvailable(d: Denom): boolean {
+ const available = this.availableFor(d);
+ return available !== null && this.countFor('g', d) > available;
+ }
+
+ /** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
+ canGiveMore(d: Denom): boolean {
+ const available = this.availableFor(d);
+ return available === null || this.countFor('g', d) < available;
+ }
+
+ /** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
+ netFor(d: Denom): number {
+ return this.countFor('r', d) - this.countFor('g', d);
+ }
+
+ /** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
+ drawerAfter(d: Denom): number | null {
+ const available = this.availableFor(d);
+ return available === null ? null : available + this.netFor(d);
+ }
+
+ /**
+ * The denominations this exchange changes, with the drawer's count before and after, so the effect shows
+ * while the counts are being typed. The branch's total for each denomination moves by the same amount, as
+ * the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
+ */
+ get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
+ if (!this.positionLoaded || !this.cashier) return [];
+ return this.denoms
+ .filter((d) => this.netFor(d) !== 0)
+ .map((d) => ({
+ id: d.id,
+ label: d.label,
+ kind: d.kind,
+ net: this.netFor(d),
+ before: this.availableFor(d) ?? 0,
+ after: this.drawerAfter(d) ?? 0
+ }));
}
/** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
- if (!this.positionLoaded) return [];
+ // Without a cashier every denomination would read as "0 available"; the missing cashier is the
+ // thing to tell the user, not a stock shortage.
+ if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
@@ -147,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
}
get valid(): boolean {
- return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
+ return (
+ this.matches &&
+ !!this.cashier &&
+ !this.hasGivenOverdraft &&
+ !this.panFormatInvalid &&
+ !this.panMissing &&
+ this.customerIdentified &&
+ !this.loadingCustomer
+ );
}
- toggleAccountHolder(): void {
- this.isAccountHolder = !this.isAccountHolder;
- if (!this.isAccountHolder) this.accountNo = '';
+ setCustomerMode(mode: 'existing' | 'walkin'): void {
+ if (this.customerMode === mode) return;
+ this.customerMode = mode;
+ this.selectedCustomer = null;
+ this.walkInName = '';
+ this.walkInMobile = '';
+ this.panNumber = '';
+ }
+
+ /** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
+ openCustomerSearch(): void {
+ this.dialog
+ // An existing customer without an account is still a known customer, so the account is not required.
+ .open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
+ .afterClosed()
+ .subscribe((result: CustomerSearchResult | undefined) => {
+ if (!result?.clientId) return;
+ this.loadCustomer(result);
+ });
+ }
+
+ private loadCustomer(result: CustomerSearchResult): void {
+ this.loadingCustomer = true;
+ const id = String(result.clientId);
+ forkJoin({
+ client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
+ kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
+ }).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
+ const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
+ this.selectedCustomer = {
+ clientId: result.clientId,
+ displayName: client?.displayName || result.displayName,
+ cifNumber: client?.cifNumber || null,
+ mobileNo: client?.mobileNo || result.mobileNo || null,
+ officeName: client?.officeName || result.officeName || null,
+ accountNo: result.accountNo || null,
+ accountType: result.accountType || null,
+ kycPan,
+ panVerified: !!kyc?.panVerified,
+ kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
+ };
+ this.panNumber = kycPan ? kycPan.toUpperCase() : '';
+ this.loadingCustomer = false;
+ });
+ }
+
+ clearCustomer(): void {
+ this.selectedCustomer = null;
+ this.panNumber = '';
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
this.note = '';
- this.accountNo = '';
- this.customerIdProof = '';
- this.isAccountHolder = false;
+ this.customerMode = 'walkin';
+ this.selectedCustomer = null;
+ this.walkInName = '';
+ this.walkInMobile = '';
+ this.panNumber = '';
}
submit(): void {
@@ -171,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
+ const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
+ const pan = this.panValue || null;
const payload: any = {
exchangeDate: this.exchangeDate,
note: this.note || null,
- customerIdProof: this.customerIdProof.trim() || null,
- accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
+ clientId: customer?.clientId ?? null,
+ accountNo: customer?.accountNo ?? null,
+ customerName: customer ? customer.displayName : this.walkInName.trim() || null,
+ customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
+ panNumber: pan,
+ customerIdProof: pan,
received: build('r'),
given: build('g')
};
diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html
index 4e85528dd..943ff9323 100644
--- a/src/app/cashier/cash-history/cash-history.component.html
+++ b/src/app/cashier/cash-history/cash-history.component.html
@@ -11,25 +11,11 @@
CASHIER · HISTORY
Cash Movement History
-
All transfers, settlements, and inter-cashier movements. Reverse any active transaction.
+
All transfers, settlements, inter-cashier movements and cash exchanges.
refresh Refresh
-
- @if (reversingId !== null) {
-
- warning
- Reverse #{{ reversingId }} ? This cannot be undone.
-
- Confirm Reversal
- Cancel
-
- }
-
@if (loading) {
Loading…
}
@@ -134,11 +120,6 @@
>
visibility
- @if (!r.isReversed) {
-
- undo
-
- }