Files
cbsnexora/src/app/locker/shared/locker-client-picker/locker-client-picker.component.ts
T

179 lines
5.7 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/.
*/
/** Angular Imports */
import {
Component,
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../../locker.service';
/** What a pick hands back — enough to show the client and link them. */
export interface PickedClient {
clientId: number;
displayName: string;
mobileNo?: string | null;
officeName?: string | null;
}
/**
* Client name box with type-ahead (case-insensitive, from 2 letters) over existing clients. Emits the picked client,
* and emits null as soon as the text is changed away from it, so a stale client id is never kept for another name.
*/
@Component({
selector: 'mifosx-locker-client-picker',
template: `
<mat-form-field appearance="fill" class="picker-field">
<mat-label>{{ label | translate }}</mat-label>
<input
matInput
[formControl]="nameControl"
[matAutocomplete]="auto"
[required]="required"
autocomplete="off"
[readonly]="disabled"
/>
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayName" (optionSelected)="onSelected($event)">
@for (c of suggestions; track c.clientId) {
<mat-option [value]="c">
<span class="opt-name">{{ c.displayName }}</span>
<span class="opt-meta">
{{ 'ID' | translate }} {{ c.clientId }}
@if (c.mobileNo) {
· {{ c.mobileNo }}
}
@if (c.officeName) {
· {{ c.officeName }}
}
</span>
</mat-option>
}
</mat-autocomplete>
@if (pickedId) {
<mat-hint class="picked">{{ 'Client ID' | translate }} {{ pickedId }}</mat-hint>
} @else if (!disabled) {
<mat-hint>{{ 'Type 2+ letters and pick a client' | translate }}</mat-hint>
}
</mat-form-field>
`,
styles: [
`
.picker-field {
width: 100%;
}
.opt-name {
display: block;
font-weight: 500;
line-height: 1.2;
}
.opt-meta {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.2;
}
.picked {
color: #1976d2;
font-weight: 600;
}
`
],
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
MatAutocompleteModule
]
})
export class LockerClientPickerComponent implements OnInit, OnChanges {
private lockerService = inject(LockerService);
private destroyRef = inject(DestroyRef);
@Input() label = 'Client';
@Input() required = false;
@Input() disabled = false;
/** Pre-selected client, e.g. when an existing application is opened. */
@Input() clientId: number | null = null;
@Input() clientName: string | null = null;
/** Client ids that may not be picked here (e.g. the main applicant in the joint-holder list). */
@Input() excludeIds: number[] = [];
@Output() picked = new EventEmitter<PickedClient | null>();
nameControl = new FormControl<any>('');
suggestions: PickedClient[] = [];
pickedId: number | null = null;
private pickedName = '';
ngOnInit(): void {
this.nameControl.valueChanges
.pipe(
// A picked option puts the object in the box; only typed text counts as a change.
filter((value: any): value is string => typeof value === 'string'),
debounceTime(300),
map((value: string) => value.trim()),
distinctUntilChanged(),
switchMap((name: string) => {
if (this.pickedId && name !== this.pickedName) {
this.pickedId = null;
this.pickedName = '';
this.picked.emit(null);
}
if (name.length < 2 || this.pickedId) {
return of([]);
}
return this.lockerService.searchClientsByName(name).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rows: any[]) => {
// The search returns one row per account; keep one per client.
const seen = new Set<number>();
this.suggestions = (rows ?? [])
.filter(
(r: any) =>
r?.clientId && !this.excludeIds.includes(r.clientId) && !seen.has(r.clientId) && seen.add(r.clientId)
)
.slice(0, 10);
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['clientId'] || changes['clientName']) {
this.pickedId = this.clientId ?? null;
this.pickedName = this.clientName ?? '';
this.nameControl.setValue(this.pickedName, { emitEvent: false });
}
}
onSelected(event: MatAutocompleteSelectedEvent): void {
const client: PickedClient = event.option.value;
this.pickedId = client.clientId;
this.pickedName = client.displayName;
this.suggestions = [];
this.nameControl.setValue(client.displayName, { emitEvent: false });
this.picked.emit(client);
}
displayName = (value: any): string => (typeof value === 'string' ? value : (value?.displayName ?? ''));
}