/** * 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; /** * Whether each customer is listed once. The search returns a row per account, which an account * screen wants; a screen that is choosing the customer itself (an agent, a guarantor) wants each * person once however many accounts they hold. */ uniqueCustomers?: boolean; /** * What the caller had already typed, to search for on opening. All digits is read as a client ID, * or a mobile number once it is as long as one; anything else is a name. */ prefill?: string; } type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; /** An Indian mobile number is ten digits; a client ID is never that long. */ const MOBILE_NUMBER_LENGTH = 10; @Component({ selector: 'mifosx-customer-search-dialog', standalone: true, imports: [ CommonModule, FormsModule, ReactiveFormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatProgressBarModule, MatSnackBarModule ], template: `
person_search

Search Customer

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

@if (loading) { }
Client ID badge Name person Mobile Number call Account Number account_balance_wallet 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' }}
@if (!loading && results.length === 0) {
search_off
No matching customer found
Check the spelling, or try fewer fields.
} @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: [ ` :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, .csd-field--wide { grid-column: span 2; } .csd-field--address { grid-column: 1 / -1; } .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--wide, .csd-field--address { grid-column: span 1; } } ` ] }) export class CustomerSearchDialogComponent implements OnInit { private fb = inject(FormBuilder); 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[] = []; 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({ clientId: [''], name: [''], mobileNo: [''], accountNumber: [''], aadhaarNumber: [''], panNumber: [''], address: [''], savings: [false], current: [false], fixedDeposit: [false], recurringDeposit: [false] }); // What the caller had typed already is searched for straight away, so opening the dialog from a // half-filled field does not mean typing it a second time. const typed = (this.data?.prefill ?? '').trim(); if (typed) { const field = this.fieldFor(typed); this.form.get(field)?.setValue(typed); this.onSearch(); } } /** * Which field something typed belongs in. Digits alone are a client ID - they are short - until * they are as long as a mobile number, and anything with letters in it is a name. */ private fieldFor(typed: string): 'clientId' | 'mobileNo' | 'name' { if (!/^\d+$/.test(typed)) { return 'name'; } return typed.length >= MOBILE_NUMBER_LENGTH ? 'mobileNo' : 'clientId'; } /** Keeps a numeric field to digits however it was filled - typed, pasted or dropped. */ digitsOnly(name: string): void { const control = this.form.get(name); const value = String(control?.value ?? ''); const digits = value.replace(/\D/g, ''); if (value !== digits) { control?.setValue(digits, { emitEvent: 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.clientId && !v.name && !v.mobileNo && !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.clientId) params = params.set('clientId', v.clientId); if (v.name) params = params.set('name', v.name); if (v.mobileNo) params = params.set('mobileNo', v.mobileNo); 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('/customer-search', { params }).subscribe({ next: (data) => { this.results = this.data?.uniqueCustomers ? this.onePerCustomer(data || []) : data || []; this.searched = true; this.loading = false; }, error: (err) => { this.loading = false; this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); } }); } /** The search answers per account; one row per customer is the first of each person's rows. */ private onePerCustomer(rows: CustomerSearchResult[]): CustomerSearchResult[] { const seen = new Set(); return rows.filter((row) => !seen.has(row.clientId) && !!seen.add(row.clientId)); } onClear(): void { this.form.reset({ clientId: '', name: '', mobileNo: '', 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(); } }