/** * 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: `
person_search

Search Customer

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

@if (loading) { }
Name person 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 { 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); 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({ 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('/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(); } }