feat: Implement price info card component and product categories with grid view
- Added `price-info-card.component` to display total amount, tax, and payable amount. - Integrated price masking directive for formatted price display. - Created `categories.component` to list product categories with loading skeletons. - Implemented `grid-view.component` for displaying products in a grid layout. - Enhanced `products.component` to manage category selection and product display. - Introduced new models for product categories and stock responses. - Updated `main.service.ts` to fetch product categories and stock data. - Refactored `POSStore` to manage state for products, categories, and order items. - Added utility functions for price formatting and number normalization. - Included placeholder images for product and logo.
This commit is contained in:
@@ -54,7 +54,6 @@ export class InventoryMovementListComponent {
|
||||
|
||||
getAll() {
|
||||
this.loading.set(true);
|
||||
console.log(this.inventoryId);
|
||||
|
||||
return this.service.getMovements(this.inventoryId, this.movementType).subscribe({
|
||||
next: (res) => {
|
||||
|
||||
@@ -92,13 +92,10 @@ export class InventoriesTransferFormComponent {
|
||||
count: [1, [Validators.required, Validators.min(1)]],
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(this.form.controls.items);
|
||||
}
|
||||
}
|
||||
|
||||
clearSelectedProductsInForm($e: TableRowUnSelectEvent<IInventoryProduct>) {
|
||||
console.log($e.index);
|
||||
if ($e.index != null) {
|
||||
this.form.controls.items.removeAt($e.index);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<div class="bg-surface-card w-md shrink-0 h-full overflow-hidden rounded-xl p-4 pb-0 flex flex-col">
|
||||
<div class="shrink-0 sticky top-0">
|
||||
<customers-select-field [canInsert]="true" />
|
||||
</div>
|
||||
<hr />
|
||||
<div class="grow overflow-auto flex flex-col">
|
||||
<div class="flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-2 text-muted-color">
|
||||
<i class="pi pi-shopping-cart"></i>
|
||||
<span class="text-base font-bold">سفارشها</span>
|
||||
</div>
|
||||
<button pButton type="button" icon="pi pi-refresh" outlined size="small" (click)="clearOrderList()">
|
||||
پاک کردن سفارشها
|
||||
</button>
|
||||
</div>
|
||||
@if (!inOrderProducts().length) {
|
||||
<div class="grow flex flex-col gap-5 justify-center items-center text-center text-muted-color">
|
||||
<i class="pi pi-inbox text-6xl!"></i>
|
||||
<span class="text-lg text-muted-color">هیچ سفارشی ثبت نشده است.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
@for (inOrderProduct of inOrderProducts(); track inOrderProduct.productId) {
|
||||
<div class="border border-surface-border rounded-xl p-2 shadow-sm">
|
||||
<div class="flex gap-3 items-stretch">
|
||||
<img [src]="placeholderImage" class="w-24 h-24 rounded-md bg-surface-100 object-cover shrink-0" />
|
||||
<div class="flex flex-col grow justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<span class="font-bold text-lg">{{ inOrderProduct.product.name }}</span>
|
||||
<span class="text-sm text-muted-color">{{ inOrderProduct.product.sku }}</span>
|
||||
</div>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
class="ms-auto"
|
||||
(click)="removeProductFromOrder(inOrderProduct.productId)"
|
||||
></button>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-auto">
|
||||
<p-inplace>
|
||||
<ng-template #display>
|
||||
<span class="font-bold text-lg shrink-0" [appPriceMask]="inOrderProduct.fee"></span>
|
||||
</ng-template>
|
||||
<ng-template #content let-closeCallback="closeCallback">
|
||||
<p-input-number
|
||||
type="text"
|
||||
inputStyleClass="w-24"
|
||||
[min]="0"
|
||||
[(ngModel)]="inOrderProduct.fee"
|
||||
(onBlur)="
|
||||
updateInOrderProductFee(inOrderProduct.productId, $event, inOrderProduct.fee); closeCallback()
|
||||
"
|
||||
/>
|
||||
</ng-template>
|
||||
</p-inplace>
|
||||
<div class="ms-auto shrink-0">
|
||||
<app-uikit-counter
|
||||
[min]="1"
|
||||
[max]="inOrderProduct.maxQuantity"
|
||||
[value]="inOrderProduct.quantity"
|
||||
(valueChange)="updateInOrderProductQuantity(inOrderProduct.productId, $event)"
|
||||
></app-uikit-counter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-col sticky bottom-0 py-2">
|
||||
<pos-order-price-info-card />
|
||||
<div class="grid grid-cols grid-cols-2 sticky bottom-0 gap-2 pt-4">
|
||||
<button pButton type="button" label="ثبت سفارش" severity="primary" icon="pi pi-check" class="w-full"></button>
|
||||
<button pButton type="button" label="لغو" outlined icon="pi pi-times" class="w-full"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CustomersSelectComponent } from '@/modules/customers/components/select/select.component';
|
||||
import { PriceMaskDirective } from '@/shared/directives';
|
||||
import { UikitCounterComponent } from '@/uikit';
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonDirective } from 'primeng/button';
|
||||
import { InplaceModule } from 'primeng/inplace';
|
||||
import { InputNumber } from 'primeng/inputnumber';
|
||||
import images from 'src/assets/images';
|
||||
import { POSStore } from '../../store';
|
||||
import { POSOrderPriceInfoCardComponent } from './price-info-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'pos-order-card',
|
||||
templateUrl: './order-card.component.html',
|
||||
imports: [
|
||||
CustomersSelectComponent,
|
||||
ButtonDirective,
|
||||
PriceMaskDirective,
|
||||
UikitCounterComponent,
|
||||
POSOrderPriceInfoCardComponent,
|
||||
FormsModule,
|
||||
InplaceModule,
|
||||
InputNumber,
|
||||
],
|
||||
})
|
||||
export class PosOrderCardComponent {
|
||||
constructor(private store: POSStore) {}
|
||||
|
||||
placeholderImage = images.placeholders.default;
|
||||
|
||||
inOrderProducts = computed(() => this.store.inOrderProducts());
|
||||
|
||||
value = signal(0);
|
||||
|
||||
removeProductFromOrder(productId: number) {
|
||||
this.store.removeFromInOrderProducts(productId);
|
||||
}
|
||||
|
||||
updateInOrderProductQuantity(productId: number, quantity: number) {
|
||||
this.store.updateInOrderProductQuantity(productId, quantity);
|
||||
}
|
||||
|
||||
updateInOrderProductFee(productId: number, $event: Event, prevFee: string) {
|
||||
const fee = $event.target ? String(($event.target as HTMLInputElement).ariaValueNow) : prevFee;
|
||||
|
||||
this.store.updateInOrderProductFee(productId, fee);
|
||||
}
|
||||
|
||||
clearOrderList() {
|
||||
this.store.resetInOrderProducts();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<p-card class="border border-surface-border">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between">
|
||||
<span>جمع قیمت</span>
|
||||
<span [appPriceMask]="priceInfo().totalAmount"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>مالیات (۱۰٪)</span>
|
||||
<span [appPriceMask]="priceInfo().taxAmount"></span>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="flex justify-between font-bold text-lg">
|
||||
<span>مجموع کل</span>
|
||||
<span [appPriceMask]="priceInfo().payableAmount"></span>
|
||||
</div>
|
||||
</div>
|
||||
</p-card>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PriceMaskDirective } from '@/shared/directives';
|
||||
import { Component, computed } from '@angular/core';
|
||||
import { Card } from 'primeng/card';
|
||||
import { POSStore } from '../../store';
|
||||
|
||||
@Component({
|
||||
selector: 'pos-order-price-info-card',
|
||||
templateUrl: './price-info-card.component.html',
|
||||
imports: [Card, PriceMaskDirective],
|
||||
})
|
||||
export class POSOrderPriceInfoCardComponent {
|
||||
constructor(private store: POSStore) {}
|
||||
|
||||
priceInfo = computed(() => this.store.orderPricingInfo());
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class="flex gap-3 overflow-auto">
|
||||
@if (loading()) {
|
||||
@for (i of [1, 2, 3, 4, 5]; track i) {
|
||||
<p-skeleton width="6rem" height="2.5rem" />
|
||||
}
|
||||
} @else {
|
||||
@for (category of categories(); track category.id) {
|
||||
<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' : '')
|
||||
"
|
||||
(click)="changeSelectedCategory(category)"
|
||||
>
|
||||
<span class="text-sm text-muted-color font-bold"> {{ category.name }}</span>
|
||||
<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' : '')
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ category.productCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Maybe } from '@/core';
|
||||
import { Component, EventEmitter, Output, signal } from '@angular/core';
|
||||
import { Skeleton } from 'primeng/skeleton';
|
||||
import { map } from 'rxjs';
|
||||
import { IPosProductCategoriesResponse } from '../../models';
|
||||
import { PosService } from '../../services/main.service';
|
||||
|
||||
@Component({
|
||||
selector: 'pos-product-categories',
|
||||
templateUrl: './categories.component.html',
|
||||
imports: [Skeleton],
|
||||
})
|
||||
export class PosProductCategoriesComponent {
|
||||
@Output() categoryChange = new EventEmitter<number>();
|
||||
|
||||
constructor(private service: PosService) {
|
||||
this.getCategories();
|
||||
}
|
||||
|
||||
categories = signal<Maybe<IPosProductCategoriesResponse[]>>(null);
|
||||
loading = signal(true);
|
||||
|
||||
selectedCategory = signal<Maybe<IPosProductCategoriesResponse>>(null);
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="grid 2xl:grid-cols-8 xl:grid-cols-6 lg:grid-cols-4 grid-cols-2 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="flex-1 aspect-[0.9]">
|
||||
<p-skeleton width="100%" height="100%"></p-skeleton>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
@for (product of products(); track product.id) {
|
||||
<div class="flex-1 bg-surface-card rounded-lg p-1 shadow-xs">
|
||||
<img [src]="productPlaceholder" class="w-full aspect-[1.3] object-cover rounded-md" />
|
||||
<div class="mt-2">
|
||||
<span class="text-lg font-bold">
|
||||
{{ product.product.name }}
|
||||
<small class="text-xs text-muted-color">({{ product.quantity }})</small>
|
||||
</span>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<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>
|
||||
}
|
||||
}
|
||||
</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-grid-view',
|
||||
templateUrl: './grid-view.component.html',
|
||||
imports: [PriceMaskDirective, ButtonDirective, Skeleton],
|
||||
})
|
||||
export class PosProductsGridViewComponent {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-muted-color">
|
||||
<i class="pi pi-list"></i>
|
||||
<span class="text-base font-bold">لیست کالاها</span>
|
||||
</div>
|
||||
<button pButton icon="pi pi-search"></button>
|
||||
</div>
|
||||
<pos-product-categories (categoryChange)="onCategoryChange($event)" />
|
||||
|
||||
<pos-products-grid-view />
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Maybe } from '@/core';
|
||||
import { Component, computed, Input, signal } from '@angular/core';
|
||||
import { ButtonDirective } from 'primeng/button';
|
||||
import { POSStore } from '../../store';
|
||||
import { PosProductCategoriesComponent } from './categories.component';
|
||||
import { PosProductsGridViewComponent } from './grid-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'pos-products',
|
||||
templateUrl: './products.component.html',
|
||||
imports: [PosProductCategoriesComponent, ButtonDirective, PosProductsGridViewComponent],
|
||||
})
|
||||
export class PosProductsComponent {
|
||||
@Input() activeCategory = signal<Maybe<number>>(null);
|
||||
|
||||
constructor(private store: POSStore) {
|
||||
this.store.initial();
|
||||
}
|
||||
|
||||
readonly activatedCategoryProducts = computed(() => this.store.activatedCategoryProducts());
|
||||
readonly stock = computed(() => this.store.stock());
|
||||
|
||||
onCategoryChange(categoryId: number) {
|
||||
this.store.changeActiveCategory(categoryId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// const baseUrl = '/api/v1/product-categories';
|
||||
const baseUrl = '/api/v1/pos';
|
||||
|
||||
// export const PRODUCT_CATEGORIES_API_ROUTES = {
|
||||
// list: () => `${baseUrl}`,
|
||||
// single: (categoryId: string) => `${baseUrl}/${categoryId}`,
|
||||
// };
|
||||
export const POS_API_ROUTES = {
|
||||
info: () => `${baseUrl}`,
|
||||
stock: () => `${baseUrl}/stock`,
|
||||
productCategories: () => `${baseUrl}/product-categories`,
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// export * from './apiRoutes';
|
||||
export * from './apiRoutes';
|
||||
export * from './routes';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './io';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IPosOrderItem, IPosProductSummary } from './types';
|
||||
|
||||
export interface IPosInfoRawResponse {
|
||||
store: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IPosInfoResponse extends IPosInfoRawResponse {}
|
||||
|
||||
export interface IPosStockRawResponse {
|
||||
id: number;
|
||||
quantity: number;
|
||||
product: IPosProductSummary;
|
||||
}
|
||||
export interface IPosStockResponse extends IPosStockRawResponse {}
|
||||
|
||||
export interface IPosProductCategoriesRawResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
totalQuantity: number;
|
||||
productCount: number;
|
||||
}
|
||||
export interface IPosProductCategoriesResponse extends IPosProductCategoriesRawResponse {}
|
||||
|
||||
export interface IPosOrderRequest {
|
||||
customerId?: number;
|
||||
items: IPosOrderItem[];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface IPosProductSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
salePrice: string;
|
||||
category: ICategorySummary;
|
||||
}
|
||||
|
||||
interface ICategorySummary {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IPosOrderItem {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
fee: string;
|
||||
}
|
||||
|
||||
export interface IPosInOrderProduct extends IPosOrderItem {
|
||||
product: IPosProductSummary;
|
||||
maxQuantity: number;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IPaginatedResponse } from '@/core/models/service.model';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { POS_API_ROUTES } from '../constants';
|
||||
import {
|
||||
IPosInfoRawResponse,
|
||||
IPosInfoResponse,
|
||||
IPosProductCategoriesRawResponse,
|
||||
IPosProductCategoriesResponse,
|
||||
IPosStockRawResponse,
|
||||
IPosStockResponse,
|
||||
} from '../models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PosService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private apiRoutes = POS_API_ROUTES;
|
||||
|
||||
getInfo(): Observable<IPosInfoResponse> {
|
||||
return this.http.get<IPosInfoRawResponse>(this.apiRoutes.info());
|
||||
}
|
||||
|
||||
getStock(): Observable<IPaginatedResponse<IPosStockResponse>> {
|
||||
return this.http.get<IPaginatedResponse<IPosStockRawResponse>>(this.apiRoutes.stock());
|
||||
}
|
||||
|
||||
getCategories(): Observable<IPaginatedResponse<IPosProductCategoriesResponse>> {
|
||||
return this.http.get<IPaginatedResponse<IPosProductCategoriesRawResponse>>(
|
||||
this.apiRoutes.productCategories(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Maybe } from '@/core';
|
||||
import { computed, Injectable, 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';
|
||||
|
||||
interface IPosState {
|
||||
getStockLoading: boolean;
|
||||
stock: Maybe<IPosStockResponse[]>;
|
||||
getInfoLoading: boolean;
|
||||
info: Maybe<IPosInfoResponse>;
|
||||
getProductCategoriesLoading: boolean;
|
||||
productCategories: Maybe<IPosProductCategoriesResponse[]>;
|
||||
activeProductCategory: Maybe<number>;
|
||||
inOrderProducts: IPosInOrderProduct[];
|
||||
}
|
||||
|
||||
export const INITIAL_POS_STATE: IPosState = {
|
||||
getStockLoading: true,
|
||||
stock: null,
|
||||
getInfoLoading: true,
|
||||
info: null,
|
||||
getProductCategoriesLoading: true,
|
||||
productCategories: null,
|
||||
activeProductCategory: null,
|
||||
inOrderProducts: [],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'any' })
|
||||
export class POSStore {
|
||||
constructor(private service: PosService) {}
|
||||
|
||||
private state$ = signal<IPosState>({ ...INITIAL_POS_STATE });
|
||||
|
||||
readonly stock = computed(() => this.state$().stock);
|
||||
readonly getStockLoading = computed(() => this.state$().getStockLoading);
|
||||
readonly info = computed(() => this.state$().info);
|
||||
readonly getInfoLoading = computed(() => this.state$().getInfoLoading);
|
||||
readonly inOrderProducts = computed(() => this.state$().inOrderProducts);
|
||||
readonly productCategories = computed(() => this.state$().productCategories);
|
||||
readonly getProductCategoriesLoading = computed(() => this.state$().getProductCategoriesLoading);
|
||||
readonly activeProductCategory = computed(() => this.state$().activeProductCategory);
|
||||
readonly activatedCategoryProducts = computed(() => {
|
||||
if (this.activeProductCategory() === 0) {
|
||||
return this.state$().stock;
|
||||
}
|
||||
return this.state$().stock?.filter(
|
||||
(s) => s.product.category.id === this.state$().activeProductCategory,
|
||||
);
|
||||
});
|
||||
|
||||
readonly orderPricingInfo = computed(() => {
|
||||
const totalAmount = this.inOrderProducts().reduce(
|
||||
(acc, curr) => acc + parseFloat(curr.fee) * curr.quantity,
|
||||
0,
|
||||
);
|
||||
const taxAmount = totalAmount * 0.1;
|
||||
return {
|
||||
totalItems: this.inOrderProducts().reduce((acc, curr) => acc + curr.quantity, 0),
|
||||
totalBaseAmount: this.inOrderProducts().reduce(
|
||||
(acc, curr) => acc + parseFloat(curr.product.salePrice) * curr.quantity,
|
||||
0,
|
||||
),
|
||||
totalAmount,
|
||||
taxAmount,
|
||||
payableAmount: totalAmount + taxAmount,
|
||||
};
|
||||
});
|
||||
|
||||
setState(partial: Partial<IPosState>) {
|
||||
this.state$.set({ ...this.state$(), ...partial });
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state$.set({ ...INITIAL_POS_STATE });
|
||||
}
|
||||
|
||||
initial() {
|
||||
this.getInfo();
|
||||
this.getStock();
|
||||
this.getProductCategories();
|
||||
}
|
||||
|
||||
fillInitial(data: Partial<IPosState>) {
|
||||
this.state$.set({ ...INITIAL_POS_STATE, ...data });
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
this.setState({ getInfoLoading: true });
|
||||
this.service.getInfo().subscribe({
|
||||
next: (res) => {
|
||||
this.setState({ info: res, getInfoLoading: false });
|
||||
},
|
||||
error: () => {
|
||||
this.setState({ getInfoLoading: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStock() {
|
||||
this.setState({ getStockLoading: true });
|
||||
this.service.getStock().subscribe({
|
||||
next: (res) => {
|
||||
this.setState({ stock: res.data, getStockLoading: false });
|
||||
},
|
||||
error: () => {
|
||||
this.setState({ getStockLoading: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getProductCategories() {
|
||||
this.setState({ getProductCategoriesLoading: true });
|
||||
this.service
|
||||
.getCategories()
|
||||
.pipe(
|
||||
map((res) => {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 0,
|
||||
name: 'همه',
|
||||
totalQuantity: res.data.reduce((acc, curr) => acc + curr.totalQuantity, 0),
|
||||
productCount: res.data.reduce((acc, curr) => acc + curr.productCount, 0),
|
||||
},
|
||||
...res.data,
|
||||
],
|
||||
meta: res.meta,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.setState({
|
||||
productCategories: res.data,
|
||||
getProductCategoriesLoading: false,
|
||||
activeProductCategory: res.data[0]?.id,
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.setState({ getProductCategoriesLoading: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
changeActiveCategory(categoryId: number) {
|
||||
this.setState({ activeProductCategory: categoryId });
|
||||
}
|
||||
|
||||
addToInOrderProducts(productId: number) {
|
||||
const productStock = this.state$().stock?.find((s) => s.product.id === productId);
|
||||
if (!productStock) return;
|
||||
const { product } = productStock;
|
||||
|
||||
if (this.state$().inOrderProducts.some((p) => p.productId === productId)) {
|
||||
this.updateInOrderProductQuantity(
|
||||
productId,
|
||||
this.state$().inOrderProducts.find((p) => p.productId === productId)!.quantity + 1,
|
||||
);
|
||||
} else {
|
||||
this.setState({
|
||||
inOrderProducts: [
|
||||
...this.state$().inOrderProducts,
|
||||
{
|
||||
product,
|
||||
productId: product.id,
|
||||
quantity: 1,
|
||||
fee: product.salePrice,
|
||||
maxQuantity: productStock.quantity,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeFromInOrderProducts(productId: number) {
|
||||
this.setState({
|
||||
inOrderProducts: this.state$().inOrderProducts.filter((p) => p.productId !== productId),
|
||||
});
|
||||
}
|
||||
|
||||
updateInOrderProductQuantity(productId: number, quantity: number) {
|
||||
const inOrderProducts = this.state$().inOrderProducts;
|
||||
|
||||
if (quantity < 1) {
|
||||
this.removeFromInOrderProducts(productId);
|
||||
} else if (inOrderProducts.findIndex((p) => p.productId === productId) === -1) {
|
||||
this.addToInOrderProducts(productId);
|
||||
} else {
|
||||
const updatedProducts = inOrderProducts.map((p) => {
|
||||
if (p.productId === productId) {
|
||||
return { ...p, quantity: Math.min(quantity, p.maxQuantity) };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
this.setState({ inOrderProducts: updatedProducts });
|
||||
}
|
||||
}
|
||||
|
||||
updateInOrderProductFee(productId: number, fee: string) {
|
||||
const inOrderProducts = this.state$().inOrderProducts;
|
||||
|
||||
if (inOrderProducts.some((p) => p.productId === productId)) {
|
||||
const updatedProducts = inOrderProducts.map((p) => {
|
||||
if (p.productId === productId) {
|
||||
return { ...p, fee: fee };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
this.setState({ inOrderProducts: updatedProducts });
|
||||
}
|
||||
}
|
||||
|
||||
resetInOrderProducts() {
|
||||
this.setState({ inOrderProducts: [] });
|
||||
}
|
||||
}
|
||||
@@ -1 +1,26 @@
|
||||
<div class=""></div>
|
||||
<div class="w-svw h-svh bg-surface-ground flex flex-col gap-4 p-4">
|
||||
<div class="w-full flex items-center gap-4 shrink-0 justify-between">
|
||||
<div class="flex gap-2 items-center">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="bg-surface-card py-1 px-3 rounded-md shadow-sm">
|
||||
<span [jalaliDate]="now" jalaliFormat="dddd YYYY/MM/DD" class="text-base"></span>
|
||||
</div>
|
||||
<div class="bg-surface-card py-1 px-3 rounded-md shadow-sm">
|
||||
<span class="text-base"> فروشنده شمارهی ۱ </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 grow overflow-hidden">
|
||||
<div class="grow h-full overflow-auto">
|
||||
<pos-products />
|
||||
</div>
|
||||
<div class="shrink-0 h-full">
|
||||
<pos-order-card />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Maybe } from '@/core';
|
||||
import { JalaliDateDirective } from '@/shared/directives';
|
||||
import { Component, signal } from '@angular/core';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pos',
|
||||
templateUrl: './pos.component.html',
|
||||
imports: [JalaliDateDirective, PosProductsComponent, PosOrderCardComponent],
|
||||
})
|
||||
export class POSComponent {
|
||||
constructor() {}
|
||||
constructor(private service: PosService) {
|
||||
this.getInfo();
|
||||
}
|
||||
|
||||
placeholderLogo = images.placeholders.logo;
|
||||
|
||||
now = new Date();
|
||||
inventoryId = 2;
|
||||
|
||||
info = signal<Maybe<IPosInfoResponse>>(null);
|
||||
infoLoading = signal(true);
|
||||
|
||||
getInfo() {
|
||||
this.infoLoading.set(true);
|
||||
this.service.getInfo().subscribe({
|
||||
next: (res) => {
|
||||
this.info.set(res);
|
||||
this.infoLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.infoLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ export class ProductBrandsComponent {
|
||||
getData() {
|
||||
this.loading.set(true);
|
||||
this.productBrandService.getAll().subscribe((res) => {
|
||||
console.log(res);
|
||||
|
||||
this.loading.set(false);
|
||||
this.items.set(res.data);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="flex flex-col gap-3 grow">
|
||||
<p-card header="قیمت">
|
||||
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4">
|
||||
<app-input label="قیمت پایهی فروش" [control]="form.controls.basePrice" name="basePrice" />
|
||||
<app-input label="قیمت پایهی فروش" [control]="form.controls.salePrice" name="salePrice" />
|
||||
</form>
|
||||
</p-card>
|
||||
<p-card header="اطلاعات تکمیلی">
|
||||
|
||||
@@ -56,7 +56,7 @@ export class ProductFullFormComponent {
|
||||
description: [this.initialData?.description || null],
|
||||
categoryId: [this.initialData?.category?.id || null],
|
||||
imageUrl: [null],
|
||||
basePrice: [this.initialData?.basePrice || 0, [Validators.required, Validators.min(0)]],
|
||||
salePrice: [this.initialData?.salePrice || 0, [Validators.required, Validators.min(0)]],
|
||||
});
|
||||
|
||||
get backRoute() {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export interface IProductRawResponse {
|
||||
brand?: IProductBrand;
|
||||
category?: IProductCategory;
|
||||
// supplierId: number;
|
||||
basePrice?: string;
|
||||
salePrice?: string;
|
||||
count?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ProductsComponent {
|
||||
}
|
||||
|
||||
columns = [
|
||||
{ field: 'id', header: 'شناسه' },
|
||||
{ field: 'sku', header: 'شناسه' },
|
||||
{ field: 'name', header: 'نام' },
|
||||
{
|
||||
field: 'brand',
|
||||
@@ -39,6 +39,11 @@ export class ProductsComponent {
|
||||
return item.category?.name || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'salePrice',
|
||||
header: 'قیمت فروش',
|
||||
type: 'price',
|
||||
},
|
||||
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
|
||||
{ field: 'updatedAt', header: 'تاریخ بهروزرسانی', type: 'date' },
|
||||
] as IColumn[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Maybe } from '@/core';
|
||||
import { PurchaseFormComponent } from '@/modules/purchases/components/form.component';
|
||||
import { KeyValueComponent } from '@/shared/components';
|
||||
import { DetailsInfoCardComponent } from '@/shared/components/detailsInfoCard/details-info-card.component';
|
||||
import priceMaskUtils from '@/utils/price-mask.utils';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { Button, ButtonDirective } from 'primeng/button';
|
||||
@@ -86,12 +87,17 @@ export class ProductComponent {
|
||||
{
|
||||
icon: 'pi pi-dollar',
|
||||
label: 'قیمت پایه',
|
||||
value: this.product()?.basePrice?.toString() || 'تعیین نشده',
|
||||
value: this.product()?.salePrice
|
||||
? priceMaskUtils.formatWithCurrency(this.product()!.salePrice!)
|
||||
: 'تعیین نشده',
|
||||
},
|
||||
{
|
||||
icon: 'pi pi-dollar',
|
||||
label: 'میانگین قیمت',
|
||||
value: this.product()?.avgCost?.toString() || 'تعیین نشده',
|
||||
label: 'میانگین قیمت خرید',
|
||||
value: this.product()?.salePrice
|
||||
? priceMaskUtils.formatWithCurrency(this.product()!.avgCost!)
|
||||
: 'تعیین نشده',
|
||||
type: 'price',
|
||||
},
|
||||
{
|
||||
icon: 'pi pi-clock',
|
||||
|
||||
@@ -78,9 +78,6 @@ export class PurchaseFormComponent {
|
||||
|
||||
submit() {
|
||||
this.form.markAllAsTouched();
|
||||
console.log(this.form);
|
||||
console.log(this.form.value);
|
||||
|
||||
if (this.form.valid) {
|
||||
this.form.disable();
|
||||
const data = this.form.value as IPurchaseRequest;
|
||||
|
||||
-1
@@ -172,7 +172,6 @@ export class PurchaseReceiptTemplateComponent {
|
||||
return { ...res, productId: item.product?.id };
|
||||
}),
|
||||
} as IPurchaseRequest;
|
||||
console.log(payload);
|
||||
this.form.disable();
|
||||
|
||||
this.service.create(payload).subscribe({
|
||||
|
||||
@@ -43,8 +43,6 @@ export class UsersComponent {
|
||||
getData() {
|
||||
this.loading.set(true);
|
||||
this.userService.getAll().subscribe((res) => {
|
||||
console.log(res);
|
||||
|
||||
this.loading.set(false);
|
||||
this.items.set(res.data);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user