feat: enhance POS module with dynamic routing, improved state management, and new search functionality

This commit is contained in:
2025-12-26 16:47:47 +03:30
parent abfb2f3479
commit c6efda319c
20 changed files with 264 additions and 109 deletions
+1
View File
@@ -2,5 +2,6 @@ export * from './maybe.model';
export * from './namedRoutes.model';
export * from './navigation.model';
export * from './route-utils.model';
export * from './summary';
export * from './tag';
export * from './user.model';
+4
View File
@@ -0,0 +1,4 @@
export default interface ISummary {
id: number;
name: string;
}
@@ -8,7 +8,6 @@ import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { ConfirmDialog } from 'primeng/confirmdialog';
import { InventoryPosAccountFormComponent } from '../../components/posAccounts/form.component';
import { IInventoryBankAccountResponse } from '../../models/bankAccounts.io';
import { InventoryBankAccountsService } from '../../services';
import { InventoryCreateBankAccountFormComponent } from './create-bank-account-form.component';
@@ -22,7 +21,6 @@ import { InventoryBankAccountFormComponent } from './form.component';
Card,
ButtonDirective,
ConfirmDialog,
InventoryPosAccountFormComponent,
InventoryBankAccountFormComponent,
InventoryCreateBankAccountFormComponent,
],
@@ -8,7 +8,7 @@
<div
[class]="
'h-10 rounded-lg px-3 flex items-center gap-3 border border-surface-border cursor-pointer bg-surface-100 hover:bg-surface-200 transition-all' +
(selectedCategory()?.id === category.id ? ' bg-surface-card' : '')
(selectedCategory() === category.id ? ' bg-surface-card' : '')
"
(click)="changeSelectedCategory(category)"
>
@@ -16,7 +16,7 @@
<div
[class]="
'flex items-center justify-center py-0.5 px-2 rounded-sm bg-surface-300 text-xs text-muted-color' +
(selectedCategory()?.id === category.id ? ' bg-amber-400! text-white' : '')
(selectedCategory() === category.id ? ' bg-amber-400! text-white' : '')
"
>
<span>
@@ -1,9 +1,6 @@
import { Maybe } from '@/core';
import { Component, EventEmitter, Output, signal } from '@angular/core';
import { Component, EventEmitter, inject, Output } from '@angular/core';
import { Skeleton } from 'primeng/skeleton';
import { map } from 'rxjs';
import { IPosProductCategoriesResponse } from '../../models';
import { PosService } from '../../services/main.service';
import { POSStore } from '../../store';
@Component({
selector: 'pos-product-categories',
@@ -13,51 +10,14 @@ import { PosService } from '../../services/main.service';
export class PosProductCategoriesComponent {
@Output() categoryChange = new EventEmitter<number>();
constructor(private service: PosService) {
this.getCategories();
}
private readonly store = inject(POSStore);
categories = signal<Maybe<IPosProductCategoriesResponse[]>>(null);
loading = signal(true);
categories = this.store.productCategories;
loading = this.store.getProductCategoriesLoading;
selectedCategory = signal<Maybe<IPosProductCategoriesResponse>>(null);
selectedCategory = this.store.activeProductCategory;
changeSelectedCategory(category: IPosProductCategoriesResponse) {
if (category.id !== this.selectedCategory()?.id) {
this.selectedCategory.set(category);
this.categoryChange.emit(category.id);
}
}
getCategories() {
this.loading.set(true);
this.service
.getCategories()
.pipe(
map((res) => {
return {
data: [
{
id: 0,
name: 'همه',
productCount: res.data.reduce((acc, category) => acc + category.productCount, 0),
totalQuantity: res.data.reduce((acc, category) => acc + category.totalQuantity, 0),
},
...res.data,
],
meta: res.meta,
};
}),
)
.subscribe({
next: (res) => {
this.selectedCategory.set(res.data[0]);
this.categories.set(res.data);
this.loading.set(false);
},
error: (err) => {
this.loading.set(false);
},
});
changeSelectedCategory(category: { id: number; name: string }) {
this.store.changeActiveCategory(category.id);
}
}
@@ -0,0 +1,30 @@
<div class="flex flex-col gap-3 flex-wrap">
@if (loading()) {
@for (_ of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15]; track $index) {
<div class="w-100 h-12">
<p-skeleton width="100%" height="100%"></p-skeleton>
</div>
}
} @else {
@for (product of products(); track product.id) {
<div class="flex items-center bg-surface-card rounded-lg p-1 shadow-xs gap-2">
<div class="grow flex items-center gap-2">
<img [src]="productPlaceholder" class="w-12 h-12 object-cover rounded-md" />
<div class="flex flex-col gap-2">
<span class="text-lg font-bold">
{{ product.product.name }}
<small class="text-xs text-muted-color">({{ product.quantity }})</small>
</span>
<span class="text-sm text-muted-color">
{{ product.product.sku }}
</span>
</div>
</div>
<div class="shrink-0 flex items-center justify-between gap-3">
<span [appPriceMask]="product.product.salePrice" class="text-base font-bold"></span>
<button pButton type="button" icon="pi pi-plus" size="small" (click)="addProduct(product.product)"></button>
</div>
</div>
}
}
</div>
@@ -0,0 +1,25 @@
import { PriceMaskDirective } from '@/shared/directives';
import { Component, computed } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
import images from 'src/assets/images';
import { IPosProductSummary } from '../../models/types';
import { POSStore } from '../../store';
@Component({
selector: 'pos-products-list-view',
templateUrl: './list-view.component.html',
imports: [PriceMaskDirective, ButtonDirective, Skeleton],
})
export class PosProductsListViewComponent {
constructor(private store: POSStore) {}
products = computed(() => this.store.activatedCategoryProducts());
loading = computed(() => this.store.getStockLoading());
productPlaceholder = images.placeholders.default;
addProduct(product: IPosProductSummary) {
this.store.addToInOrderProducts(product.id);
}
}
@@ -4,9 +4,23 @@
<i class="pi pi-list"></i>
<span class="text-base font-bold">لیست کالاها</span>
</div>
<button pButton icon="pi pi-search"></button>
<div class="flex items-center gap-2">
<app-search-input (search)="onSearch($event)"></app-search-input>
<div class="card p-2! flex justify-center">
<p-selectbutton [options]="viewOptions" [(ngModel)]="viewType" optionValue="value">
<ng-template #item let-item>
<i [class]="item.icon"></i>
</ng-template>
</p-selectbutton>
</div>
</div>
</div>
<pos-product-categories (categoryChange)="onCategoryChange($event)" />
<pos-products-grid-view />
@if (viewType === "grid") {
<pos-products-grid-view />
} @else {
<pos-products-list-view />
}
</div>
@@ -1,25 +1,54 @@
import { Maybe } from '@/core';
import { Component, computed, Input, signal } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { POSStore } from '../../store';
import { SearchInputComponent } from '@/shared/components/search/search-input.component';
import { Component, computed, inject, Input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SelectButton } from 'primeng/selectbutton';
import { POSStore, TViewType } from '../../store';
import { PosProductCategoriesComponent } from './categories.component';
import { PosProductsGridViewComponent } from './grid-view.component';
import { PosProductsListViewComponent } from './list-view.component';
@Component({
selector: 'pos-products',
templateUrl: './products.component.html',
imports: [PosProductCategoriesComponent, ButtonDirective, PosProductsGridViewComponent],
imports: [
PosProductCategoriesComponent,
PosProductsListViewComponent,
SelectButton,
FormsModule,
PosProductsGridViewComponent,
SearchInputComponent,
],
})
export class PosProductsComponent {
@Input() activeCategory = signal<Maybe<number>>(null);
constructor(private store: POSStore) {
private readonly store = inject(POSStore);
constructor() {
this.store.initial();
}
viewOptions = [
{ value: 'list', icon: 'pi pi-list' },
{ value: 'grid', icon: 'pi pi-th-large' },
];
readonly activatedCategoryProducts = computed(() => this.store.activatedCategoryProducts());
readonly stock = computed(() => this.store.stock());
get viewType() {
return this.store.viewType();
}
set viewType(val: TViewType) {
this.store.updateViewType(val);
}
onSearch(searchTerm: string) {
this.store.updateSearchQuery(searchTerm);
this.store.getStock();
}
onCategoryChange(categoryId: number) {
this.store.changeActiveCategory(categoryId);
}
@@ -1,8 +1,8 @@
const baseUrl = '/api/v1/pos';
export const POS_API_ROUTES = {
info: () => `${baseUrl}`,
stock: () => `${baseUrl}/stock`,
productCategories: () => `${baseUrl}/product-categories`,
submitOrder: () => `${baseUrl}/orders/create`,
info: (posId: number) => `${baseUrl}/${posId}`,
stock: (posId: number) => `${baseUrl}/${posId}/stock`,
productCategories: (posId: number) => `${baseUrl}/${posId}/product-categories`,
submitOrder: (posId: number) => `${baseUrl}/${posId}/orders/create`,
};
@@ -5,11 +5,11 @@ export type TPOSRouteNames = 'POS';
export const posNamedRoutes: NamedRoutes<TPOSRouteNames> = {
POS: {
path: 'pos',
path: 'pos/:posId',
loadComponent: () => import('../../views/pos.component').then((m) => m.POSComponent),
meta: {
title: 'نقطه فروش',
pagePath: () => '/pos',
title: 'نقاط فروش',
pagePath: (params) => `/pos/${params.posId}`,
},
},
};
+13 -5
View File
@@ -1,10 +1,18 @@
import { IPosOrderItem, IPosProductSummary } from './types';
import ISummary from '@/core/models/summary';
import { IBankAccountSummary, IPosOrderItem, IPosProductSummary } from './types';
export interface IPosInfoRawResponse {
store: {
id: number;
name: string;
};
id: number;
name: string;
code: string;
description: string;
bankAccountId: number;
inventoryId: number;
createdAt: string;
updatedAt: string;
deletedAt: null;
inventory: ISummary;
bankAccount: IBankAccountSummary;
}
export interface IPosInfoResponse extends IPosInfoRawResponse {}
+13
View File
@@ -1,3 +1,5 @@
import ISummary from '@/core/models/summary';
export interface IPosProductSummary {
id: number;
name: string;
@@ -21,3 +23,14 @@ export interface IPosInOrderProduct extends IPosOrderItem {
product: IPosProductSummary;
maxQuantity: number;
}
export interface IBankAccountSummary {
id: number;
name: string;
accountNumber: string;
branch: IBankAccountBranchSummary;
}
interface IBankAccountBranchSummary extends ISummary {
bank: ISummary;
}
+15 -8
View File
@@ -19,21 +19,28 @@ export class PosService {
private apiRoutes = POS_API_ROUTES;
getInfo(): Observable<IPosInfoResponse> {
return this.http.get<IPosInfoRawResponse>(this.apiRoutes.info());
getInfo(posId: number): Observable<IPosInfoResponse> {
return this.http.get<IPosInfoRawResponse>(this.apiRoutes.info(posId));
}
getStock(): Observable<IPaginatedResponse<IPosStockResponse>> {
return this.http.get<IPaginatedResponse<IPosStockRawResponse>>(this.apiRoutes.stock());
getStock(
posId: number,
searchQuery: string = '',
): Observable<IPaginatedResponse<IPosStockResponse>> {
return this.http.get<IPaginatedResponse<IPosStockRawResponse>>(this.apiRoutes.stock(posId), {
params: {
q: searchQuery,
},
});
}
getCategories(): Observable<IPaginatedResponse<IPosProductCategoriesResponse>> {
getCategories(posId: number): Observable<IPaginatedResponse<IPosProductCategoriesResponse>> {
return this.http.get<IPaginatedResponse<IPosProductCategoriesRawResponse>>(
this.apiRoutes.productCategories(),
this.apiRoutes.productCategories(posId),
);
}
submitOrder(payload: IPosOrderRequest): Observable<any> {
return this.http.post<any>(this.apiRoutes.submitOrder(), payload);
submitOrder(posId: number, payload: IPosOrderRequest): Observable<any> {
return this.http.post<any>(this.apiRoutes.submitOrder(posId), payload);
}
}
+31 -6
View File
@@ -1,11 +1,15 @@
import { Maybe } from '@/core';
import { ICustomerResponse } from '@/modules/customers/models';
import { computed, Injectable, signal } from '@angular/core';
import { computed, Inject, Injectable, InjectionToken, signal } from '@angular/core';
import { map } from 'rxjs';
import { IPosInfoResponse, IPosProductCategoriesResponse, IPosStockResponse } from '../models';
import { IPosInOrderProduct } from '../models/types';
import { PosService } from '../services/main.service';
export const POS_ID = new InjectionToken<number>('POS_ID');
export type TViewType = 'list' | 'grid';
interface IPosState {
getStockLoading: boolean;
stock: Maybe<IPosStockResponse[]>;
@@ -16,6 +20,8 @@ interface IPosState {
activeProductCategory: Maybe<number>;
inOrderProducts: IPosInOrderProduct[];
selectedCustomer: Maybe<ICustomerResponse>;
viewType: TViewType;
searchQuery: string;
}
export const INITIAL_POS_STATE: IPosState = {
@@ -28,11 +34,19 @@ export const INITIAL_POS_STATE: IPosState = {
activeProductCategory: null,
inOrderProducts: [],
selectedCustomer: null,
viewType: 'grid',
searchQuery: '',
};
@Injectable({ providedIn: 'any' })
export class POSStore {
constructor(private service: PosService) {}
constructor(
@Inject(POS_ID) posId: number,
private service: PosService,
) {
this.posId = posId;
this.initial();
}
private state$ = signal<IPosState>({ ...INITIAL_POS_STATE });
@@ -45,6 +59,8 @@ export class POSStore {
readonly getProductCategoriesLoading = computed(() => this.state$().getProductCategoriesLoading);
readonly activeProductCategory = computed(() => this.state$().activeProductCategory);
readonly selectedCustomer = computed(() => this.state$().selectedCustomer);
readonly viewType = computed(() => this.state$().viewType);
readonly searchQuery = computed(() => this.state$().searchQuery);
readonly activatedCategoryProducts = computed(() => {
if (this.activeProductCategory() === 0) {
return this.state$().stock;
@@ -72,6 +88,8 @@ export class POSStore {
};
});
posId!: number;
setState(partial: Partial<IPosState>) {
this.state$.set({ ...this.state$(), ...partial });
}
@@ -92,7 +110,7 @@ export class POSStore {
getInfo() {
this.setState({ getInfoLoading: true });
this.service.getInfo().subscribe({
this.service.getInfo(this.posId).subscribe({
next: (res) => {
this.setState({ info: res, getInfoLoading: false });
},
@@ -104,7 +122,7 @@ export class POSStore {
getStock() {
this.setState({ getStockLoading: true });
this.service.getStock().subscribe({
this.service.getStock(this.posId, this.state$().searchQuery).subscribe({
next: (res) => {
this.setState({ stock: res.data, getStockLoading: false });
},
@@ -117,7 +135,7 @@ export class POSStore {
getProductCategories() {
this.setState({ getProductCategoriesLoading: true });
this.service
.getCategories()
.getCategories(this.posId)
.pipe(
map((res) => {
return {
@@ -216,6 +234,13 @@ export class POSStore {
}
}
updateViewType(viewType: TViewType) {
this.setState({ viewType });
}
updateSearchQuery(searchQuery: string) {
this.setState({ searchQuery });
}
resetInOrderProducts() {
this.setState({ inOrderProducts: [] });
}
@@ -233,6 +258,6 @@ export class POSStore {
fee: item.fee,
})),
};
this.service.submitOrder(orderPayload).subscribe();
this.service.submitOrder(this.posId, orderPayload).subscribe();
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="w-10 h-10">
<img [src]="placeholderLogo" alt="Logo" class="w-full h-full object-cover rounded-md bg-surface-card" />
</div>
<span class="text-lg font-bold">{{ info()?.store?.name }}</span>
<span class="text-lg font-bold">{{ info()?.name }} - فروشگاه {{ info()?.inventory?.name }}</span>
</div>
<div class="flex gap-2 items-center">
<div class="bg-surface-card py-1 px-3 rounded-md shadow-sm">
+26 -22
View File
@@ -1,40 +1,44 @@
import { Maybe } from '@/core';
import { JalaliDateDirective } from '@/shared/directives';
import { Component, signal } from '@angular/core';
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import images from 'src/assets/images';
import { PosOrderCardComponent } from '../components/order/order-card.component';
import { PosProductsComponent } from '../components/products/products.component';
import { IPosInfoResponse } from '../models';
import { PosService } from '../services/main.service';
import { POSStore, POS_ID } from '../store';
@Component({
selector: 'app-pos',
templateUrl: './pos.component.html',
imports: [JalaliDateDirective, PosProductsComponent, PosOrderCardComponent],
providers: [
{
provide: POS_ID,
useFactory: (route: ActivatedRoute) => Number(route.snapshot.params['posId']),
deps: [ActivatedRoute],
},
POSStore,
],
})
export class POSComponent {
constructor(private service: PosService) {
this.getInfo();
}
private readonly store = inject(POSStore);
placeholderLogo = images.placeholders.logo;
now = new Date();
inventoryId = 2;
info = signal<Maybe<IPosInfoResponse>>(null);
infoLoading = signal(true);
info = this.store.info;
infoLoading = this.store.getInfoLoading;
getInfo() {
this.infoLoading.set(true);
this.service.getInfo().subscribe({
next: (res) => {
this.info.set(res);
this.infoLoading.set(false);
},
error: () => {
this.infoLoading.set(false);
},
});
}
// getInfo() {
// this.infoLoading.set(true);
// this.service.getInfo(Number(this.posId)).subscribe({
// next: (res) => {
// this.info.set(res);
// this.infoLoading.set(false);
// },
// error: () => {
// this.infoLoading.set(false);
// },
// });
// }
}
@@ -0,0 +1 @@
<input pInputText type="text" placeholder="جستجو..." (input)="onSearchInput($event.target.value)" />
@@ -0,0 +1,36 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { InputText } from 'primeng/inputtext';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-search-input',
templateUrl: 'search-input.component.html',
imports: [InputText],
})
export class SearchInputComponent implements OnInit, OnDestroy {
private searchSubject = new Subject<string>();
private destroy$ = new Subject<void>();
@Output() search = new EventEmitter<string>();
ngOnInit() {
this.searchSubject.pipe(debounceTime(300), takeUntil(this.destroy$)).subscribe((searchTerm) => {
this.onSearch(searchTerm);
});
}
onSearchInput(value: string) {
this.searchSubject.next(value);
}
onSearch(searchTerm: string) {
console.log('Search:', searchTerm);
this.search.emit(searchTerm);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}