179 lines
5.7 KiB
TypeScript
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 ?? ''));
|
|
}
|