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 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 18:41:24 +05:30
co-authored by Claude Sonnet 5.5
parent 211027b9d8
commit 915deddb74
19 changed files with 2601 additions and 540 deletions
@@ -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: `
<h2 mat-dialog-title>Search Customer</h2>
<mat-dialog-content>
<p class="hint-text">
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
</p>
<form [formGroup]="form" class="search-form">
<mat-form-field appearance="outline" class="field">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Customer name" />
</mat-form-field>
<div class="csd-head">
<div class="csd-head__icon"><mat-icon>person_search</mat-icon></div>
<div class="csd-head__text">
<h2 class="csd-head__title">Search Customer</h2>
<p class="csd-head__sub">
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
</p>
</div>
<button mat-icon-button type="button" class="csd-head__close" (click)="onClose()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-form-field appearance="outline" class="field">
<mat-label>Account Number</mat-label>
<input matInput formControlName="accountNumber" placeholder="Savings / Current / FD / RD" />
</mat-form-field>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<mat-form-field appearance="outline" class="field">
<mat-label>Aadhaar Number</mat-label>
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar" maxlength="12" />
</mat-form-field>
<mat-dialog-content class="csd-content">
<form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)">
<div class="csd-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name">
<mat-label>Name</mat-label>
<mat-icon matPrefix>person</mat-icon>
<input matInput formControlName="name" placeholder="Customer name" autocomplete="off" />
</mat-form-field>
<mat-form-field appearance="outline" class="field">
<mat-label>PAN Number</mat-label>
<input
matInput
formControlName="panNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase;"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Account Number</mat-label>
<mat-icon matPrefix>account_balance_wallet</mat-icon>
<input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" />
</mat-form-field>
<mat-form-field appearance="outline" class="field field--wide">
<mat-label>Address</mat-label>
<input matInput formControlName="address" placeholder="Street, town, city, or postal code" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Aadhaar Number</mat-label>
<mat-icon matPrefix>fingerprint</mat-icon>
<input
matInput
formControlName="aadhaarNumber"
placeholder="12-digit Aadhaar"
maxlength="12"
inputmode="numeric"
autocomplete="off"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>PAN Number</mat-label>
<mat-icon matPrefix>credit_card</mat-icon>
<input
matInput
formControlName="panNumber"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
(input)="upperCase('panNumber')"
class="csd-upper"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--address">
<mat-label>Address</mat-label>
<mat-icon matPrefix>place</mat-icon>
<input
matInput
formControlName="address"
placeholder="Street, town, city, or postal code"
autocomplete="off"
/>
</mat-form-field>
</div>
<div class="csd-types">
<span class="csd-types__label">Account Type</span>
<div class="csd-types__pills">
@for (t of accountTypeOptions; track t.key) {
<button
type="button"
class="csd-pill"
[class.csd-pill--on]="isTypeOn(t.key)"
[attr.aria-pressed]="isTypeOn(t.key)"
(click)="toggleType(t.key)"
>
<mat-icon>{{ isTypeOn(t.key) ? 'check' : t.icon }}</mat-icon
>{{ t.label }}
</button>
}
</div>
<span class="csd-types__hint">Leave all unselected to search every account type</span>
</div>
</form>
<div class="account-type-filter" [formGroup]="form">
<span class="account-type-filter__label">Account Type</span>
<mat-checkbox formControlName="savings">Savings</mat-checkbox>
<mat-checkbox formControlName="current">Current</mat-checkbox>
<mat-checkbox formControlName="fixedDeposit">Fixed Deposit</mat-checkbox>
<mat-checkbox formControlName="recurringDeposit">Recurring Deposit</mat-checkbox>
<span class="account-type-filter__hint">(leave all unchecked to search every account type)</span>
</div>
@if (searched) {
<div class="csd-results">
<div class="csd-results__head">{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}</div>
<div class="results" *ngIf="searched">
<p *ngIf="!loading && results.length === 0" class="no-results">No matching customer found.</p>
@if (!loading && results.length === 0) {
<div class="csd-empty">
<mat-icon>search_off</mat-icon>
<div class="csd-empty__title">No matching customer found</div>
<div class="csd-empty__sub">Check the spelling, or try fewer fields.</div>
</div>
}
<table mat-table [dataSource]="results" *ngIf="results.length > 0">
<ng-container matColumnDef="displayName">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let r">{{ r.displayName }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Account</th>
<td mat-cell *matCellDef="let r">
<span *ngIf="r.accountNo">{{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }})</span>
<span *ngIf="!r.accountNo" class="muted">No account</span>
</td>
</ng-container>
<ng-container matColumnDef="mobileNo">
<th mat-header-cell *matHeaderCellDef>Mobile</th>
<td mat-cell *matCellDef="let r">{{ r.mobileNo || '-' }}</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let r">{{ r.officeName || '-' }}</td>
</ng-container>
<ng-container matColumnDef="panNumber">
<th mat-header-cell *matHeaderCellDef>PAN</th>
<td mat-cell *matCellDef="let r">{{ r.panNumber || '-' }}</td>
</ng-container>
<ng-container matColumnDef="aadhaarNumber">
<th mat-header-cell *matHeaderCellDef>Aadhaar</th>
<td mat-cell *matCellDef="let r">{{ r.aadhaarNumber || '-' }}</td>
</ng-container>
<ng-container matColumnDef="address">
<th mat-header-cell *matHeaderCellDef>Address</th>
<td mat-cell *matCellDef="let r">{{ r.address || '-' }}</td>
</ng-container>
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let r">
<button mat-flat-button color="primary" [disabled]="!r.accountNo" (click)="onSelect(r)">Select</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
@for (r of results; track $index) {
<div class="csd-result" [class.csd-result--noacc]="!r.accountNo">
<div class="csd-avatar">{{ initials(r.displayName) }}</div>
<div class="csd-result__main">
<div class="csd-result__name">
{{ r.displayName }}
@if (r.accountNo) {
<span class="csd-badge csd-badge--type">{{ accountTypeLabel(r.accountType) }}</span>
<span class="csd-result__acc">{{ r.accountNo }}</span>
} @else {
<span class="csd-badge csd-badge--muted">No account</span>
}
</div>
<div class="csd-result__meta">
<span><mat-icon>call</mat-icon>{{ r.mobileNo || '—' }}</span>
<span><mat-icon>account_balance</mat-icon>{{ r.officeName || '—' }}</span>
<span><mat-icon>credit_card</mat-icon>PAN {{ r.panNumber || '—' }}</span>
<span><mat-icon>fingerprint</mat-icon>Aadhaar {{ r.aadhaarNumber || '—' }}</span>
</div>
@if (r.address) {
<div class="csd-result__addr"><mat-icon>place</mat-icon>{{ r.address }}</div>
}
</div>
<button mat-flat-button color="primary" [disabled]="!canSelect(r)" (click)="onSelect(r)">Select</button>
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onClose()">Close</button>
<button mat-flat-button color="primary" [disabled]="loading" (click)="onSearch()">Search</button>
<mat-dialog-actions align="end" class="csd-actions">
<button mat-button type="button" (click)="onClose()">Close</button>
<button mat-stroked-button type="button" [disabled]="loading" (click)="onClear()">Clear</button>
<button mat-flat-button color="primary" type="button" [disabled]="loading" (click)="onSearch()">
<mat-icon>search</mat-icon> Search
</button>
</mat-dialog-actions>
`,
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<CustomerSearchDialogComponent>);
private snackBar = inject(MatSnackBar);
private data = inject<CustomerSearchDialogData | null>(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);
}