- 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>
681 lines
19 KiB
TypeScript
681 lines
19 KiB
TypeScript
/**
|
|
* 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 { HttpClient, HttpParams } from '@angular/common/http';
|
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
|
|
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 { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
|
|
export interface CustomerSearchResult {
|
|
clientId: number;
|
|
displayName: string;
|
|
mobileNo: string | null;
|
|
officeName: string | null;
|
|
accountNo: string | null;
|
|
accountType: 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RECURRING_DEPOSIT' | null;
|
|
panNumber: string | null;
|
|
aadhaarNumber: string | null;
|
|
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,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
ReactiveFormsModule,
|
|
MatDialogModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressBarModule,
|
|
MatSnackBarModule
|
|
],
|
|
template: `
|
|
<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>
|
|
|
|
@if (loading) {
|
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
|
}
|
|
|
|
<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" 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" 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>
|
|
|
|
@if (searched) {
|
|
<div class="csd-results">
|
|
<div class="csd-results__head">{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}</div>
|
|
|
|
@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>
|
|
}
|
|
|
|
@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" 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: [
|
|
`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
.csd-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 20px 16px 12px 24px;
|
|
}
|
|
|
|
.csd-head__icon {
|
|
display: grid;
|
|
place-items: center;
|
|
flex: 0 0 auto;
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 12px;
|
|
background: #e6f4ee;
|
|
color: #0f4f3c;
|
|
}
|
|
|
|
.csd-head__icon mat-icon {
|
|
font-size: 26px;
|
|
width: 26px;
|
|
height: 26px;
|
|
}
|
|
|
|
.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 12px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.csd-types__label {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #475569;
|
|
}
|
|
|
|
.csd-types__pills {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.csd-types__hint {
|
|
font-size: 12px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.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;
|
|
cursor: pointer;
|
|
transition:
|
|
background 0.15s ease,
|
|
border-color 0.15s ease,
|
|
color 0.15s ease;
|
|
}
|
|
|
|
.csd-pill mat-icon {
|
|
font-size: 18px;
|
|
width: 18px;
|
|
height: 18px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
`
|
|
]
|
|
})
|
|
export class CustomerSearchDialogComponent implements OnInit {
|
|
private fb = inject(FormBuilder);
|
|
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[] = [];
|
|
|
|
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 {
|
|
this.form = this.fb.group({
|
|
name: [''],
|
|
accountNumber: [''],
|
|
aadhaarNumber: [''],
|
|
panNumber: [''],
|
|
address: [''],
|
|
savings: [false],
|
|
current: [false],
|
|
fixedDeposit: [false],
|
|
recurringDeposit: [false]
|
|
});
|
|
}
|
|
|
|
/** 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':
|
|
return 'Savings';
|
|
case 'CURRENT':
|
|
return 'Current';
|
|
case 'FIXED_DEPOSIT':
|
|
return 'FD';
|
|
case 'RECURRING_DEPOSIT':
|
|
return 'RD';
|
|
default:
|
|
return '-';
|
|
}
|
|
}
|
|
|
|
onSearch(): void {
|
|
const v = this.form.value;
|
|
const accountTypes: string[] = [];
|
|
if (v.savings) accountTypes.push('SAVINGS');
|
|
if (v.current) accountTypes.push('CURRENT');
|
|
if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT');
|
|
if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT');
|
|
|
|
if (!v.name && !v.accountNumber && !v.aadhaarNumber && !v.panNumber && !v.address && accountTypes.length === 0) {
|
|
this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 });
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
let params = new HttpParams();
|
|
if (v.name) params = params.set('name', v.name);
|
|
if (v.accountNumber) params = params.set('accountNumber', v.accountNumber);
|
|
if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber);
|
|
if (v.panNumber) params = params.set('panNumber', v.panNumber);
|
|
if (v.address) params = params.set('address', v.address);
|
|
if (accountTypes.length > 0) params = params.set('accountTypes', accountTypes.join(','));
|
|
|
|
this.http.get<CustomerSearchResult[]>('/customer-search', { params }).subscribe({
|
|
next: (data) => {
|
|
this.results = data || [];
|
|
this.searched = true;
|
|
this.loading = false;
|
|
},
|
|
error: (err) => {
|
|
this.loading = false;
|
|
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
onClose(): void {
|
|
this.dialogRef.close();
|
|
}
|
|
}
|