feat: add customer selection component for individual and legal customers, update forms to utilize new component

This commit is contained in:
2026-06-05 01:33:37 +03:30
parent 694b2ec946
commit 560b3516e1
9 changed files with 264 additions and 4 deletions
@@ -0,0 +1,18 @@
<p-autocomplete
(completeMethod)="search($event)"
(onSelect)="onSelectOption($event)"
[suggestions]="suggestions() || []"
optionLabel="label"
[optionValue]="undefined"
[dropdown]="true"
[forceSelection]="false"
fluid
[minLength]="1"
placeholder="نام، شماره اقتصادی، شناسه ملی، کد پستی ...">
<ng-template let-option #item>
<div class="flex flex-col gap-1">
<span class="font-semibold">{{ option.main || '-----' }}</span>
<span class="text-muted-color text-sm">{{ option.sub }}</span>
</div>
</ng-template>
</p-autocomplete>
@@ -0,0 +1,99 @@
import { Maybe } from '@/core';
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AutoCompleteModule } from 'primeng/autocomplete';
import { CustomerSearchType, PosSaleInvoicesService } from './services';
@Component({
selector: 'pos-customer-select-field',
templateUrl: `customer-select-field.component.html`,
imports: [AutoCompleteModule, ReactiveFormsModule],
})
export class CustomerSelectFieldComponent implements OnInit, OnDestroy {
// @Input() control!: FormControl<string>;
@Input() type: CustomerSearchType = 'LEGAL';
@Input() debounceTime = 300;
@Output() selected = new EventEmitter<any>();
constructor(private readonly customersService: PosSaleInvoicesService) {}
suggestions = signal<Maybe<{ id: string; label: string; main: string; sub: string; item: any }[]>>(
null
);
private readonly search$ = new Subject<string>();
private readonly destroy$ = new Subject<void>();
ngOnInit() {
this.search$
.pipe(
debounceTime(this.debounceTime),
distinctUntilChanged(),
switchMap((query) => {
if (!query?.trim()) {
return of([]);
}
const request$ = this.customersService.getByType(this.type, query);
return request$.pipe(
switchMap((res) => of(res.data ?? [])),
catchError(() => of([]))
);
}),
takeUntil(this.destroy$)
)
.subscribe((items: any[]) => {
this.suggestions.set(items.map((item) => this.mapOption(item)));
});
}
search(event: { query: string }) {
this.search$.next(event.query ?? '');
}
onSelectOption(event: { value?: { item?: any } }) {
const item = event?.value?.item;
if (item) {
this.selected.emit(item);
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
private mapOption(item: any): { id: string; label: string; main: string; sub: string; item: any } {
if (this.type === 'INDIVIDUAL') {
const firstName = String(item?.first_name ?? '').trim();
const lastName = String(item?.last_name ?? '').trim();
const fullName = `${firstName} ${lastName}`.trim();
const nationalId = String(item?.national_id ?? '').trim();
const mobile = String(item?.mobile_number ?? '').trim();
const sub = [nationalId, mobile].filter(Boolean).join(' | ');
return {
id: nationalId || mobile || fullName,
label: [fullName, nationalId, mobile].filter(Boolean).join(' - '),
main: fullName,
sub,
item,
};
}
const name = String(item?.name ?? '').trim();
const registrationNumber = String(item?.registration_number ?? '').trim();
const economicCode = String(item?.economic_code ?? '').trim();
const sub = [registrationNumber, economicCode].filter(Boolean).join(' | ');
return {
id: economicCode || registrationNumber || name,
label: [name, registrationNumber, economicCode].filter(Boolean).join(' - '),
main: name,
sub,
item,
};
}
}
@@ -0,0 +1,37 @@
export interface ILegalCustomerRawResponse {
id: string;
legal: {
name?: string;
economic_code?: string;
postal_code?: string;
registration_number?: string;
};
}
export interface ILegalCustomerResponse {
customer_id: string;
name?: string;
economic_code?: string;
postal_code?: string;
registration_number?: string;
}
export interface IIndividualCustomerRawResponse {
id: string;
individual: {
first_name?: string;
last_name?: string;
economic_code?: string;
postal_code?: string;
mobile_number?: string;
national_id?: string;
};
}
export interface IIndividualCustomerResponse {
customer_id?: string;
first_name?: string;
last_name?: string;
economic_code?: string;
postal_code?: string;
mobile_number?: string;
national_id?: string;
}
@@ -0,0 +1,63 @@
import { IListingResponse } from '@/core/models/service.model';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
import {
IIndividualCustomerRawResponse,
IIndividualCustomerResponse,
ILegalCustomerRawResponse,
ILegalCustomerResponse,
} from '../models';
export type CustomerSearchType = 'LEGAL' | 'INDIVIDUAL';
@Injectable({ providedIn: 'root' })
export class PosSaleInvoicesService {
constructor(private http: HttpClient) {}
private apiRoutes = `/api/v1/pos/customers`;
getIndividual(q: string): Observable<IListingResponse<IIndividualCustomerResponse>> {
return this.http
.get<IListingResponse<IIndividualCustomerRawResponse>>(this.apiRoutes, {
params: {
type: 'INDIVIDUAL',
q,
},
})
.pipe(
map((res) => ({
...res,
data: res.data.map((item) => ({
customer_id: item.id,
...item.individual,
})),
}))
);
}
getLegal(q: string): Observable<IListingResponse<ILegalCustomerResponse>> {
return this.http
.get<IListingResponse<ILegalCustomerRawResponse>>(this.apiRoutes, {
params: {
type: 'LEGAL',
q,
},
})
.pipe(
map((res) => ({
...res,
data: res.data.map((item) => ({
customer_id: item.id,
...item.legal,
})),
}))
);
}
getByType(
type: CustomerSearchType,
q: string
): Observable<IListingResponse<IIndividualCustomerResponse | ILegalCustomerResponse>> {
return type === 'INDIVIDUAL' ? this.getIndividual(q) : this.getLegal(q);
}
}
@@ -1,5 +1,9 @@
<form [formGroup]="form" (submit)="submit()">
<p-message severity="info"> وارد کردن شماره اقتصادی و یا کد ملی به همراه کد پستی الزامی است. </p-message>
<pos-customer-select-field
type="INDIVIDUAL"
[debounceTime]="400"
(selected)="onCustomerSelected($event)" />
<field-first-name [control]="form.controls.first_name" />
<field-last-name [control]="form.controls.last_name" />
@@ -15,6 +15,8 @@ import { Message } from 'primeng/message';
import { Observable } from 'rxjs';
import { IIndividualCustomer } from '../../../models/customer';
import { PosLandingStore } from '../../../store/main.store';
import { CustomerSelectFieldComponent } from '../customerSelectField/customer-select-field.component';
import { IIndividualCustomerResponse } from '../customerSelectField/models';
@Component({
selector: 'pos-customer-individual-form',
@@ -28,6 +30,7 @@ import { PosLandingStore } from '../../../store/main.store';
PostalCodeComponent,
Message,
NationalIdComponent,
CustomerSelectFieldComponent,
],
})
export class CustomerIndividualFormComponent extends AbstractForm<
@@ -35,6 +38,7 @@ export class CustomerIndividualFormComponent extends AbstractForm<
IIndividualCustomer
> {
private readonly store = inject(PosLandingStore);
private selectedCustomerId?: string;
costumerInfo = computed(() => this.store.customer().info?.customer_individual);
override showSuccessMessage = false;
@@ -93,7 +97,7 @@ export class CustomerIndividualFormComponent extends AbstractForm<
return new Observable<IIndividualCustomer>((observer) => {
const info = this.form.value as IIndividualCustomer;
this.store.setCustomer({
customer_id: '',
customer_id: this.selectedCustomerId,
type: CustomerType.INDIVIDUAL,
info: {
customer_individual: {
@@ -105,4 +109,18 @@ export class CustomerIndividualFormComponent extends AbstractForm<
return observer.next(info);
});
}
onCustomerSelected(customer: IIndividualCustomerResponse) {
this.selectedCustomerId = customer?.customer_id;
this.form.patchValue(
{
first_name: customer?.first_name ?? '',
last_name: customer?.last_name ?? '',
economic_code: customer?.economic_code ?? '',
national_id: customer?.national_id ?? '',
postal_code: customer?.postal_code ?? '',
},
{ emitEvent: false }
);
}
}
@@ -2,6 +2,10 @@
<p-message severity="info" icon="pi pi-info-circle">
وارد کردن شماره اقتصادی و یا شناسه‌ ملی به همراه کد پستی الزامی است.
</p-message>
<pos-customer-select-field
type="LEGAL"
[debounceTime]="400"
(selected)="onCustomerSelected($event)" />
<field-name [control]="form.controls.name" name="name" label="نام مجموعه" />
<field-legal-economic-code [control]="form.controls.economic_code" />
<field-registration-number [control]="form.controls.registration_number" />
@@ -14,6 +14,8 @@ import { Message } from 'primeng/message';
import { Observable } from 'rxjs';
import { ILegalCustomer } from '../../../models/customer';
import { PosLandingStore } from '../../../store/main.store';
import { CustomerSelectFieldComponent } from '../customerSelectField/customer-select-field.component';
import { ILegalCustomerResponse } from '../customerSelectField/models';
@Component({
selector: 'pos-customer-legal-form',
@@ -26,10 +28,12 @@ import { PosLandingStore } from '../../../store/main.store';
RegistrationNumberComponent,
PostalCodeComponent,
Message,
CustomerSelectFieldComponent,
],
})
export class CustomerLegalFormComponent extends AbstractForm<ILegalCustomer, ILegalCustomer> {
private readonly store = inject(PosLandingStore);
private selectedCustomerId?: string;
override showSuccessMessage = false;
@@ -93,7 +97,7 @@ export class CustomerLegalFormComponent extends AbstractForm<ILegalCustomer, ILe
return new Observable<ILegalCustomer>((observer) => {
const info = this.form.value as ILegalCustomer;
this.store.setCustomer({
customer_id: '',
customer_id: this.selectedCustomerId,
type: CustomerType.LEGAL,
info: {
customer_legal: {
@@ -105,4 +109,17 @@ export class CustomerLegalFormComponent extends AbstractForm<ILegalCustomer, ILe
return observer.next(info);
});
}
onCustomerSelected(customer: ILegalCustomerResponse) {
this.selectedCustomerId = customer?.customer_id;
this.form.patchValue(
{
name: customer?.name ?? '',
economic_code: customer?.economic_code ?? '',
registration_number: customer?.registration_number ?? '',
postal_code: customer?.postal_code ?? '',
},
{ emitEvent: false }
);
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
export const environment = {
production: true,
// apiBaseUrl: 'https://psp-api.shift-am.ir',
apiBaseUrl: 'http://192.168.128.73:5002',
// apiBaseUrl: 'http://localhost:5002',
// apiBaseUrl: 'http://192.168.128.73:5002',
apiBaseUrl: 'http://localhost:5002',
host: 'localhost',
port: 5000,
enableLogging: false,