From 915deddb74e8dc4543f11bd54ee58ca532d7dd88 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:41:24 +0530 Subject: [PATCH] feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position - Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card, denomination-wise validation against the master and real-time impact preview. - Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and denomination holdings shown, blocked until both are allocated cash. - Reconciliation, exchange and inter-cashier operate on the logged-in cashier only. - New Branch Cash Position screen (vault + cashier drawers, denomination-wise). - Customer search dialog shows the KYC PAN. - Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row. - Reverse actions removed from cash transfer, settlement, exchange and inter-cashier. Co-Authored-By: Claude Sonnet 5.5 --- .../branch-cash-position.component.html | 114 +++ .../branch-cash-position.component.scss | 82 ++ .../branch-cash-position.component.ts | 112 +++ .../cash-exchange-entry.component.html | 355 +++++++-- .../cash-exchange-entry.component.scss | 720 ++++++++++++++++-- .../cash-exchange-entry.component.ts | 281 ++++++- .../cash-history/cash-history.component.html | 27 +- .../cash-history/cash-history.component.scss | 32 - .../cash-history/cash-history.component.ts | 61 +- src/app/cashier/cashier-routing.module.ts | 8 + src/app/cashier/cashier.service.ts | 25 +- .../inter-cashier-transfer.component.html | 163 +++- .../inter-cashier-transfer.component.scss | 150 ++++ .../inter-cashier-transfer.component.ts | 168 +++- .../reconciliation.component.html | 39 +- .../reconciliation.component.ts | 63 +- src/app/core/shell/shell.component.html | 46 +- src/app/core/shell/shell.component.scss | 38 + .../customer-search-dialog.component.ts | 657 ++++++++++++---- 19 files changed, 2601 insertions(+), 540 deletions(-) create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.html create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.scss create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.ts create mode 100644 src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.scss diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.html b/src/app/cashier/branch-cash-position/branch-cash-position.component.html new file mode 100644 index 000000000..d8f476a2e --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.html @@ -0,0 +1,114 @@ + + +
+
+
+
BRANCH OPERATIONS · BRANCH CASH POSITION
+

Branch Cash Position

+

+ The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash + moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it. +

+
+
+ +
+ + As of Date + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (loading) { +
Loading…
+ } + + @if (position; as p) { +
+
+ Branch{{ p.officeName }} +
+
+ Vault₹{{ p.vaultAmount | number: '1.2-2' }} +
+
+ Cashiers' drawers₹{{ p.cashiersAmount | number: '1.2-2' }} +
+
+ Bank total₹{{ p.totalAmount | number: '1.2-2' }} +
+
+ + @if (!p.vaultHasBreakdown) { +
+ No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open + the business day with a note/coin count to start tracking it. +
+ } + + + @if (rows.length > 0) { + + + + + + + + + + + + + + @for (d of rows; track d.denominationId) { + + + + + + + + + } + +
DenominationVaultCashiersBank total (count)Bank total (₹)Exchange today
+ ₹{{ d.denominationValue }} + {{ d.vaultCount }}{{ d.cashierCount }}{{ d.totalCount }}₹{{ d.totalAmount | number: '1.2-2' }} + {{ movement(d.exchangeNet) }} +
+ } +
+ +
+ + +
+ +

+ Exchange today is the notes taken in less the notes given out by cash exchanges on + {{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix + can be seen. +

+ } +
diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.scss b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss new file mode 100644 index 000000000..d86edfbbc --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss @@ -0,0 +1,82 @@ +/** + * 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/. + */ + +.bcp-error { + color: #dc2626; +} + +.bcp-note { + margin-bottom: 12px; + text-align: left; +} + +.bcp-totals { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; +} + +.bcp-total { + flex: 1 1 180px; + padding: 12px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + + span { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + strong { + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + + &--main { + background: #e6f4ee; + border-color: #1f7a4d; + + strong { + color: #0f4f3c; + } + } +} + +.bcp-card { + margin-bottom: 14px; +} + +.bcp-strong { + font-weight: 700; +} + +.bcp-move { + font-weight: 700; + color: #94a3b8; + + &--in { + color: #16a34a; + } + + &--out { + color: #dc2626; + } +} + +.bcp-foot { + margin: 0; + font-size: 12px; + color: #64748b; +} diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.ts b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts new file mode 100644 index 000000000..ef5e64330 --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts @@ -0,0 +1,112 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; + +interface BranchDenomRow { + denominationId: number; + denominationValue: number; + kind: string; + vaultCount: number; + cashierCount: number; + totalCount: number; + totalAmount: number; + /** Notes taken in less notes given out by exchanges on the day. */ + exchangeNet: number; +} + +interface BranchPosition { + officeId: number; + officeName: string; + asOfDate: string; + vaultHasBreakdown: boolean; + vaultAmount: number; + cashiersAmount: number; + totalAmount: number; + denominations: BranchDenomRow[]; +} + +/** + * What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange + * (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so + * it only shows up in this total -- +10 on ₹500, -50 on ₹100. + */ +@Component({ + selector: 'mifosx-branch-cash-position', + templateUrl: './branch-cash-position.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../cashier-position/cashier-position.component.scss', + './branch-cash-position.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule + ] +}) +export class BranchCashPositionComponent implements OnInit { + private service = inject(CashierService); + + asOfDate = ''; + loading = false; + errorMsg = ''; + position: BranchPosition | null = null; + + ngOnInit(): void { + const d = businessToday(); + this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.position = null; + this.service + .branchCashPosition(this.asOfDate) + .pipe(catchError(() => of(null))) + .subscribe((data: BranchPosition | null) => { + this.loading = false; + if (!data) { + this.errorMsg = 'Could not load the branch cash position.'; + return; + } + this.position = data; + }); + } + + onDateChange(value: string): void { + this.asOfDate = value; + this.load(); + } + + notes(): BranchDenomRow[] { + return (this.position?.denominations || []).filter((d) => d.kind !== 'coin'); + } + + coins(): BranchDenomRow[] { + return (this.position?.denominations || []).filter((d) => d.kind === 'coin'); + } + + /** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */ + movement(net: number): string { + if (!net) return '—'; + return net > 0 ? `+${net}` : `${net}`; + } +} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html index e08dc73b0..d46a2d0d6 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html @@ -22,50 +22,255 @@ - -
-
- {{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }} - Account Holder -
- - @if (isAccountHolder) { - - } @else { - person_outline Walk-in Customer - } - - @if (needsIdProof) { -
- warning - Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI) + + @if (cashier) { +
+
{{ cashierInitials }}
+
+
Cashier at the counter
+
{{ cashier.name || '—' }}
+
+ south Receives from customer + north Gives to customer +
- - } -
+
+
+ Counter{{ cashier.tellerName || '—' }} +
+
+ Branch{{ cashier.officeName || '—' }} +
+
+ Allocated today{{ + cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2') + }} +
+
+ Cash in hand₹{{ cashier.totalAmount | number: '1.2-2' }} +
+
+ + } @else if (noCashier) { +
+ person_off +
+
Your login is not linked to a cashier
+
+ A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in + as a cashier to continue. +
+
+
+ } + + +
+
+
+ groups +
+
Customer
+
Who is exchanging cash at the counter
+
+
+
+ + +
+
+ +
+ +
+ @if (customerMode === 'existing') { + @if (loadingCustomer) { +
+ autorenew +
Loading customer details…
+
+ } @else if (!selectedCustomer) { +
+ person_search +
No customer selected
+
Search by name, account no., PAN or Aadhaar
+ +
+ } @else { +
+
+
{{ customerInitials }}
+
+
{{ selectedCustomer.displayName }}
+
+ + {{ + selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe' + }} + KYC: {{ selectedCustomer.kycStatus }} + + @if (selectedCustomer.accountType) { + {{ selectedCustomer.accountType }} + } +
+
+
+ + +
+
+
+
+ CIF No.{{ selectedCustomer.cifNumber || '—' }} +
+
+ Client ID{{ selectedCustomer.clientId }} +
+
+ Mobile{{ selectedCustomer.mobileNo || '—' }} +
+
+ Branch{{ selectedCustomer.officeName || '—' }} +
+
+ Account{{ selectedCustomer.accountNo || 'No account' }} +
+
+ PAN on record + + {{ selectedCustomer.kycPan || 'Not on record' }} + @if (selectedCustomer.kycPan) { + {{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }} + } + +
+
+
+ } + } @else { +
+ + +
+ } +
+ + +
+
+ credit_card + PAN Card + {{ needsPan ? 'Mandatory' : 'Optional' }} +
+
+ + @if (panValue && !panFormatInvalid) { + check_circle + } @else if (panFormatInvalid) { + error + } +
+
+ @if (panFormatInvalid) { + PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F). + } @else if (panFromKyc) { + link Fetched from the customer's KYC record. + } @else if (panMissing) { + Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI). + } @else { + Mandatory for exchanges above ₹50,000. + } +
+
+
+
+ + + @if (availableFor(d) === null) { + — + } @else { + In drawer {{ availableFor(d) }} + @if (netFor(d) !== 0) { + + {{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }} + {{ drawerAfter(d) }} + ({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }}) + + } + } + +
@@ -78,7 +283,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -90,6 +295,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -99,7 +307,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -111,6 +319,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -131,7 +342,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -141,8 +352,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -152,7 +371,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -162,8 +381,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ 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) { + + {{ 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.

- - @if (reversingId !== null) { -
- warning - Reverse #{{ reversingId }}? This cannot be undone. - - - -
- } - @if (loading) {
Loading…
} @@ -134,11 +120,6 @@ > visibility - @if (!r.isReversed) { - - }
@@ -187,6 +168,12 @@ Date {{ detail.row.date }} + @if (detail.row.type === 'exchange') { +
+ Customer PAN + {{ detail.row.panNumber || '—' }} +
+ }
Total Amount ₹{{ detail.row.amount | number: '1.2-2' }} diff --git a/src/app/cashier/cash-history/cash-history.component.scss b/src/app/cashier/cash-history/cash-history.component.scss index 1db02c17d..17ae05de0 100644 --- a/src/app/cashier/cash-history/cash-history.component.scss +++ b/src/app/cashier/cash-history/cash-history.component.scss @@ -47,38 +47,6 @@ } } -.reverse-panel { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: #fffbeb; - border: 1px solid #fcd34d; - border-radius: 10px; - flex-wrap: wrap; - font-size: 13px; - color: #92400e; - - .reverse-icon { - color: #f59e0b; - font-size: 20px; - height: 20px; - width: 20px; - } - - .reverse-reason { - flex: 1 1 200px; - height: 34px; - padding: 0 10px; - border: 1px solid #fcd34d; - border-radius: 7px; - font-size: 13px; - font-family: inherit; - background: #fff; - color: #1a1a1a; - } -} - /* ── Action buttons cell ── */ .action-btns { display: flex; diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts index 6f49e8d53..281513c46 100644 --- a/src/app/cashier/cash-history/cash-history.component.ts +++ b/src/app/cashier/cash-history/cash-history.component.ts @@ -8,9 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; 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 { MatTabsModule } from '@angular/material/tabs'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { forkJoin, of } from 'rxjs'; @@ -31,6 +29,8 @@ interface TxnRow { reversedBy: number | null; reversedAt: string | null; reverseReason: string | null; + /** Customer PAN recorded on a cash exchange. */ + panNumber?: string | null; type: 'transfer' | 'settlement' | 'inter' | 'exchange'; } @@ -52,7 +52,10 @@ interface DetailView { loading: boolean; } -/** Unified history of all cash movements with reversal and detail view support. */ +/** + * Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed + * from here (an earlier reversal still shows as Reversed, with who, when and why). + */ @Component({ selector: 'mifosx-cash-history', templateUrl: './cash-history.component.html', @@ -63,16 +66,13 @@ interface DetailView { standalone: true, imports: [ CommonModule, - FormsModule, MatIconModule, - MatSnackBarModule, MatTabsModule, MatPaginatorModule ] }) export class CashHistoryComponent implements OnInit { private service = inject(CashierService); - private snackBar = inject(MatSnackBar); transfers: TxnRow[] = []; settlements: TxnRow[] = []; @@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit { private denomKindMap = new Map(); - reversingId: number | null = null; - reversingType: string | null = null; - reverseReason = ''; - detail: DetailView | null = null; ngOnInit(): void { @@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit { this.exchanges = (exchangeItems as any[]).map((e) => ({ id: e.id, date: e.exchangeDate, - fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + fromParty: this.exchangeParty(e), + panNumber: e.panNumber ?? null, toParty: e.performedByUsername || '—', amount: e.exchangeAmount, note: e.note, @@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit { this.exchanges = (res.pageItems ?? []).map((e: any) => ({ id: e.id, date: e.exchangeDate, - fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + fromParty: this.exchangeParty(e), + panNumber: e.panNumber ?? null, toParty: e.performedByUsername || '—', amount: e.exchangeAmount, note: e.note, @@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit { this.detail = null; } - startReverse(row: TxnRow): void { - this.reversingId = row.id; - this.reversingType = row.type; - this.reverseReason = ''; - } - - cancelReverse(): void { - this.reversingId = null; - this.reversingType = null; - this.reverseReason = ''; - } - - confirmReverse(): void { - if (!this.reversingId || !this.reversingType) return; - const id = this.reversingId; - const type = this.reversingType; - const reason = this.reverseReason; - this.cancelReverse(); - - let obs$; - if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason); - else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason); - else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason); - else obs$ = this.service.reverseInterCashierTransfer(id, reason); - - obs$.pipe(catchError(() => of(null))).subscribe((res: any) => { - if (res?.reversed) { - this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 }); - this.load(); - } else { - this.snackBar.open('Reversal failed', 'Close', { duration: 3000 }); - } - }); + /** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */ + private exchangeParty(e: any): string { + if (e.customerName) { + return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName; + } + return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer'; } typeLabel(type: string): string { diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 045252339..76947f25b 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component'; import { VoucherReportComponent } from './voucher-report/voucher-report.component'; import { BranchDayComponent } from './branch-day/branch-day.component'; import { VaultPositionComponent } from './vault-position/vault-position.component'; +import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component'; import { AccountRegisterComponent } from './account-register/account-register.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; @@ -225,6 +226,13 @@ const routes: Routes = [ component: VaultPositionComponent, data: { title: 'Vault Position', breadcrumb: 'Vault Position' } }, + { + // What the whole branch holds per denomination: the vault plus every cashier's drawer, so an + // exchange (which leaves the vault alone) is visible in the bank's total. + path: 'branch-position', + component: BranchCashPositionComponent, + data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' } + }, { // The same day book the clerk and cashier read, reached from where a principal officer // signing the day off actually looks. It carries the Officer Verification Sheet — each diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index f3700e20c..cfa8def18 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -32,9 +32,6 @@ export class CashierService { getCashExchange(id: number): Observable { return this.http.get(`/cashexchange/${id}`); } - reverseCashExchange(id: number, reason: string): Observable { - return this.http.post(`/cashexchange/${id}/reverse`, { reason }); - } // ── Tellers / Cashiers ── getTellers(officeId?: number): Observable { @@ -60,9 +57,6 @@ export class CashierService { getCashTransferById(id: number): Observable { return this.http.get(`/cashtransfer/${id}`); } - reverseCashTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashtransfer/${id}/reverse`, { reason }); - } // ── Cash Settlement ── createCashSettlement(data: any): Observable { @@ -75,9 +69,6 @@ export class CashierService { getCashSettlementById(id: number): Observable { return this.http.get(`/cashsettlement/${id}`); } - reverseCashSettlement(id: number, reason: string): Observable { - return this.http.post(`/cashsettlement/${id}/reverse`, { reason }); - } // ── Inter-cashier Transfer ── createInterCashierTransfer(data: any): Observable { @@ -90,8 +81,10 @@ export class CashierService { getInterCashierById(id: number): Observable { return this.http.get(`/cashintercashier/${id}`); } - reverseInterCashierTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashintercashier/${id}/reverse`, { reason }); + /** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */ + getInterCashierCashierSummary(cashierId: number, date: string): Observable { + const params = new HttpParams().set('cashierId', cashierId).set('date', date); + return this.http.get('/cashintercashier/cashier-summary', { params }); } // ── Reconciliation ── @@ -318,6 +311,16 @@ export class CashierService { if (asOfDate) params = params.set('asOfDate', asOfDate); return this.http.get('/cashreports/vault-position', { params }); } + /** + * Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's + * exchange movement beside it. Not for a cashier -- the server refuses it. + */ + branchCashPosition(asOfDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (asOfDate) params = params.set('asOfDate', asOfDate); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get('/cashreports/branch-position', { params }); + } excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable { let params = new HttpParams().set('threshold', String(threshold)); if (fromDate) params = params.set('fromDate', fromDate); diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html index b87a43472..45166d6e6 100644 --- a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html @@ -11,41 +11,29 @@
CASHIER · INTER-CASHIER TRANSFER

Inter-Cashier Transfer

-

Move denomination-wise cash between two cashiers within the same teller.

+

Hand over denomination-wise cash from your drawer to another cashier at your counter.

-
- @if (tellers.length === 1) { - - Teller - - - } @else { - - Teller - - @for (t of tellers; track t.id) { - {{ t.name }} - } - @if (tellers.length === 0) { - No tellers configured - } - - - } + @if (ownCashierMissing) { +
+ person_off + Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so + log in as that cashier. +
+ } +
- From (Cashier) - - @for (c of availableFrom; track c.id) { - {{ c.label }} - } - @if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) { - No cashiers - } - + Teller + + + + + + From (You) + @@ -54,7 +42,7 @@ @for (c of availableTo; track c.id) { {{ c.label }} } - @if (fromCashierId && availableTo.length === 0) { + @if (fromCashierId && !loadingCashiers && availableTo.length === 0) { No other cashiers } @@ -62,7 +50,7 @@ Date - + @@ -71,26 +59,102 @@
+ + @if (fromCashierId || toCashierId) { +
+ + arrow_forward + +
+ } + + +
+
{{ title }}
+ @if (!picked) { +
Select a cashier
+ } @else if (loading) { +
Loading…
+ } @else if (!s) { +
Could not load this cashier's details.
+ } @else { +
{{ s.cashierName || '—' }}
+
+ point_of_sale {{ s.tellerName || '—' }} + account_balance {{ s.officeName || '—' }} +
+
+
+
Allocated today
+
₹{{ s.allocatedAmount | number: '1.2-2' }}
+
+
+
Cash in hand
+
₹{{ s.totalAmount | number: '1.2-2' }}
+
+
+ @if (!s.cashAllocated) { +
+ block No cash allocated from the vault for this date — inter-cashier transfer is not + allowed. +
+ } + } +
+
+ + @if (fromSummary && toSummary && !bothAllocated) { +
+ block + Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an + inter-cashier transfer. +
+ } + @if (anyExceedsHeld) { +
+ error_outline + The sending cashier does not hold enough of the highlighted denominations. +
+ } +
+ + @if (notes.length) { - + } @for (d of notes; track d.id) { - + + + } @if (coins.length) { - + } @for (d of coins; track d.id) { - + + + } @if (denoms.length === 0) { - + }
DenominationFrom holds Count AmountTo holds
Bank NotesBank Notes
{{ d.label }} + +
@@ -99,18 +163,28 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
CoinsCoins
{{ d.label }} + +
@@ -119,17 +193,34 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ + + @if (n === null) { + — + } @else { + {{ n }} + @if (delta !== 0) { + → {{ n + delta }} + } + } + +
Total to transfer: ₹{{ total | number: '1.2-2' }} +
Verification & Reports
@@ -1166,12 +1155,9 @@
Cash & Counter
- - + @@ -1186,9 +1172,6 @@ - @@ -1209,9 +1192,6 @@ - diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index c752a9d22..95d3060f9 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -244,6 +244,44 @@ $color: #e2e4ec; } } +/* + * The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at + * the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread + * across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very + * narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was. + */ +.cashier-nav:not(.cashier-nav--clerk) { + flex-wrap: nowrap; + justify-content: space-between; + gap: 0; + padding: 4px 8px; + + .cashier-nav__item { + flex: 0 0 auto; + gap: 4px; + padding: 5px 8px; + font-size: 11.5px; + } + + @media (width <= 1240px) { + .cashier-nav__item { + padding: 5px 6px; + font-size: 11px; + } + } + + @media (width <= 1120px) { + .cashier-nav__item fa-icon { + display: none; + } + } + + @media (width <= 900px) { + flex-wrap: wrap; + justify-content: flex-start; + } +} + .sidenav { overflow-x: hidden; position: relative; diff --git a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts index 6b3aa53ce..4f47b4539 100644 --- a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts +++ b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts @@ -10,12 +10,12 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpParams } from '@angular/common/http'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; -import { MatTableModule } from '@angular/material/table'; -import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; export interface CustomerSearchResult { @@ -30,6 +30,17 @@ export interface CustomerSearchResult { address: string | null; } +/** Optional data for the dialog. */ +export interface CustomerSearchDialogData { + /** + * Whether a customer must have an account to be picked. True by default, because the account screens + * need one; a screen that only needs the customer (cash exchange) passes false. + */ + requireAccount?: boolean; +} + +type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; + @Component({ selector: 'mifosx-customer-search-dialog', standalone: true, @@ -41,173 +52,479 @@ export interface CustomerSearchResult { MatFormFieldModule, MatInputModule, MatButtonModule, - MatTableModule, - MatCheckboxModule, + MatIconModule, + MatProgressBarModule, MatSnackBarModule ], template: ` -

Search Customer

- -

- Fill in any one field, or combine several to narrow the search (e.g. name + account number). -

-
- - Name - - +
+
person_search
+
+

Search Customer

+

+ Fill in any one field, or combine several to narrow the search (e.g. name + account number). +

+
+ +
- - Account Number - - + @if (loading) { + + } - - Aadhaar Number - - + + +
+ + Name + person + + - - PAN Number - - + + Account Number + account_balance_wallet + + - - Address - - + + Aadhaar Number + fingerprint + + + + + PAN Number + credit_card + + + + + Address + place + + +
+ +
+ Account Type +
+ @for (t of accountTypeOptions; track t.key) { + + } +
+ Leave all unselected to search every account type +
- + @if (searched) { +
+
{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}
-
-

No matching customer found.

+ @if (!loading && results.length === 0) { +
+ search_off +
No matching customer found
+
Check the spelling, or try fewer fields.
+
+ } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name{{ r.displayName }}Account - {{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }}) - No account - Mobile{{ r.mobileNo || '-' }}Branch{{ r.officeName || '-' }}PAN{{ r.panNumber || '-' }}Aadhaar{{ r.aadhaarNumber || '-' }}Address{{ r.address || '-' }} - -
-
+ @for (r of results; track $index) { +
+
{{ initials(r.displayName) }}
+
+
+ {{ r.displayName }} + @if (r.accountNo) { + {{ accountTypeLabel(r.accountType) }} + {{ r.accountNo }} + } @else { + No account + } +
+
+ call{{ r.mobileNo || '—' }} + account_balance{{ r.officeName || '—' }} + credit_cardPAN {{ r.panNumber || '—' }} + fingerprintAadhaar {{ r.aadhaarNumber || '—' }} +
+ @if (r.address) { +
place{{ r.address }}
+ } +
+ +
+ } +
+ }
- - - + + + + + `, styles: [ ` - .hint-text { - color: #64748b; - font-size: 13px; - margin: 0 0 12px 0; + :host { + display: block; } - .search-form { + + .csd-head { display: flex; - flex-wrap: wrap; - gap: 12px 16px; + align-items: center; + gap: 14px; + padding: 20px 16px 12px 24px; } - .field { - flex: 1 1 220px; + + .csd-head__icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 12px; + background: #e6f4ee; + color: #0f4f3c; } - .field--wide { - flex-basis: 100%; + + .csd-head__icon mat-icon { + font-size: 26px; + width: 26px; + height: 26px; } - .account-type-filter { + + .csd-head__text { + flex: 1 1 auto; + min-width: 0; + } + + .csd-head__title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #0f172a; + } + + .csd-head__sub { + margin: 2px 0 0; + font-size: 13px; + color: #64748b; + } + + .csd-head__close { + flex: 0 0 auto; + color: #64748b; + } + + .csd-content { + padding-top: 12px; + } + + .csd-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 14px; + } + + .csd-field { + width: 100%; + } + + .csd-field--name { + grid-column: span 2; + } + + .csd-field--address { + grid-column: span 3; + } + + .csd-upper { + text-transform: uppercase; + } + + .csd-types { display: flex; align-items: center; flex-wrap: wrap; - gap: 8px 16px; - margin: 4px 0 16px 0; + gap: 8px 12px; + margin-top: 14px; } - .account-type-filter__label { + + .csd-types__label { font-size: 13px; font-weight: 600; color: #475569; } - .account-type-filter__hint { + + .csd-types__pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .csd-types__hint { font-size: 12px; color: #94a3b8; } - .results { - margin-top: 16px; - overflow-x: auto; - } - table { - width: 100%; - } - th { - font-weight: 600; + + .csd-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px 0 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 500; color: #475569; - background-color: #f8fafc; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } - td { - color: #334155; - } - .muted { + + .csd-pill mat-icon { + font-size: 18px; + width: 18px; + height: 18px; color: #94a3b8; } - .no-results { - text-align: center; + + .csd-pill:hover { + border-color: #94a3b8; + background: #f8fafc; + } + + .csd-pill--on, + .csd-pill--on:hover { + border-color: #1f7a4d; + background: #e6f4ee; + font-weight: 600; + color: #0f4f3c; + } + + .csd-pill--on mat-icon { + color: #1f7a4d; + } + + .csd-results { + margin-top: 20px; + } + + .csd-results__head { + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; color: #64748b; - padding: 16px 0; + } + + .csd-result { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 8px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + + .csd-result:hover { + border-color: #1f7a4d; + box-shadow: 0 2px 8px rgb(15 23 42 / 8%); + } + + .csd-result--noacc { + background: #f8fafc; + } + + .csd-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #1f7a4d, #0f4f3c); + font-size: 14px; + font-weight: 700; + color: #fff; + } + + .csd-result__main { + flex: 1 1 auto; + min-width: 0; + } + + .csd-result__name { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + + .csd-result__acc { + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: #475569; + } + + .csd-badge { + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + } + + .csd-badge--type { + background: #eef2ff; + color: #3730a3; + } + + .csd-badge--muted { + background: #f1f5f9; + color: #64748b; + } + + .csd-result__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 4px; + font-size: 12px; + color: #475569; + } + + .csd-result__meta span, + .csd-result__addr { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .csd-result__meta mat-icon, + .csd-result__addr mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #94a3b8; + } + + .csd-result__addr { + display: flex; + align-items: flex-start; + margin-top: 4px; + font-size: 12px; + color: #64748b; + } + + .csd-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 28px 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + text-align: center; + } + + .csd-empty mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: #94a3b8; + } + + .csd-empty__title { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + .csd-empty__sub { + font-size: 12px; + color: #64748b; + } + + .csd-actions { + gap: 8px; + padding: 12px 24px 20px; + } + + @media (max-width: 720px) { + .csd-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .csd-field--address { + grid-column: span 2; + } + + .csd-result { + flex-wrap: wrap; + } + } + + @media (max-width: 480px) { + .csd-grid { + grid-template-columns: 1fr; + } + + .csd-field--name, + .csd-field--address { + grid-column: span 1; + } } ` ] @@ -217,20 +534,18 @@ export class CustomerSearchDialogComponent implements OnInit { private http = inject(HttpClient); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + private data = inject(MAT_DIALOG_DATA, { optional: true }); form!: FormGroup; loading = false; searched = false; results: CustomerSearchResult[] = []; - displayedColumns = [ - 'displayName', - 'account', - 'mobileNo', - 'officeName', - 'panNumber', - 'aadhaarNumber', - 'address', - 'select' + + readonly accountTypeOptions: Array<{ key: AccountTypeKey; label: string; icon: string }> = [ + { key: 'savings', label: 'Savings', icon: 'savings' }, + { key: 'current', label: 'Current', icon: 'account_balance_wallet' }, + { key: 'fixedDeposit', label: 'Fixed Deposit', icon: 'lock' }, + { key: 'recurringDeposit', label: 'Recurring Deposit', icon: 'autorenew' } ]; ngOnInit(): void { @@ -247,6 +562,48 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + /** The account screens need an account to open; a caller that only needs the customer opts out. */ + private get requireAccount(): boolean { + return this.data?.requireAccount !== false; + } + + canSelect(r: CustomerSearchResult): boolean { + return !this.requireAccount || !!r.accountNo; + } + + isTypeOn(key: AccountTypeKey): boolean { + return !!this.form?.get(key)?.value; + } + + toggleType(key: AccountTypeKey): void { + const control = this.form.get(key); + control?.setValue(!control.value); + } + + upperCase(name: string): void { + const control = this.form.get(name); + const value = String(control?.value ?? ''); + const upper = value.toUpperCase(); + if (value !== upper) { + control?.setValue(upper, { emitEvent: false }); + } + } + + /** Enter in a text box runs the search; on a button it keeps its own meaning. */ + onEnter(event: Event): void { + if ((event.target as HTMLElement)?.tagName !== 'INPUT') return; + event.preventDefault(); + if (!this.loading) this.onSearch(); + } + + initials(name: string | null): string { + const words = (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(); + } + accountTypeLabel(type: string | null): string { switch (type) { case 'SAVINGS': @@ -297,6 +654,22 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + onClear(): void { + this.form.reset({ + name: '', + accountNumber: '', + aadhaarNumber: '', + panNumber: '', + address: '', + savings: false, + current: false, + fixedDeposit: false, + recurringDeposit: false + }); + this.results = []; + this.searched = false; + } + onSelect(result: CustomerSearchResult): void { this.dialogRef.close(result); }