diff --git a/src/app/modules/inventories/components/movementList/movement-list.component.ts b/src/app/modules/inventories/components/movementList/movement-list.component.ts index 230c96b..66dbcce 100644 --- a/src/app/modules/inventories/components/movementList/movement-list.component.ts +++ b/src/app/modules/inventories/components/movementList/movement-list.component.ts @@ -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) => { diff --git a/src/app/modules/inventories/components/transfer/transfer-form.component.ts b/src/app/modules/inventories/components/transfer/transfer-form.component.ts index 52c1b77..bfaa1d7 100644 --- a/src/app/modules/inventories/components/transfer/transfer-form.component.ts +++ b/src/app/modules/inventories/components/transfer/transfer-form.component.ts @@ -92,13 +92,10 @@ export class InventoriesTransferFormComponent { count: [1, [Validators.required, Validators.min(1)]], }), ); - - console.log(this.form.controls.items); } } clearSelectedProductsInForm($e: TableRowUnSelectEvent) { - console.log($e.index); if ($e.index != null) { this.form.controls.items.removeAt($e.index); } diff --git a/src/app/modules/pos/components/order/order-card.component.html b/src/app/modules/pos/components/order/order-card.component.html new file mode 100644 index 0000000..d57ccd9 --- /dev/null +++ b/src/app/modules/pos/components/order/order-card.component.html @@ -0,0 +1,83 @@ +
+
+ +
+
+
+
+
+ + سفارش‌ها +
+ +
+ @if (!inOrderProducts().length) { +
+ + هیچ سفارشی ثبت نشده است. +
+ } @else { +
+ @for (inOrderProduct of inOrderProducts(); track inOrderProduct.productId) { +
+
+ +
+
+
+ {{ inOrderProduct.product.name }} + {{ inOrderProduct.product.sku }} +
+ +
+
+ + + + + + + + +
+ +
+
+
+
+
+ } +
+ } +
+
+ +
+ + +
+
+
diff --git a/src/app/modules/pos/components/order/order-card.component.ts b/src/app/modules/pos/components/order/order-card.component.ts new file mode 100644 index 0000000..80657fc --- /dev/null +++ b/src/app/modules/pos/components/order/order-card.component.ts @@ -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(); + } +} diff --git a/src/app/modules/pos/components/order/price-info-card.component.html b/src/app/modules/pos/components/order/price-info-card.component.html new file mode 100644 index 0000000..e5533f4 --- /dev/null +++ b/src/app/modules/pos/components/order/price-info-card.component.html @@ -0,0 +1,17 @@ + +
+
+ جمع قیمت + +
+
+ مالیات (۱۰٪) + +
+
+
+ مجموع کل + +
+
+
diff --git a/src/app/modules/pos/components/order/price-info-card.component.ts b/src/app/modules/pos/components/order/price-info-card.component.ts new file mode 100644 index 0000000..65ab416 --- /dev/null +++ b/src/app/modules/pos/components/order/price-info-card.component.ts @@ -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()); +} diff --git a/src/app/modules/pos/components/products/categories.component.html b/src/app/modules/pos/components/products/categories.component.html new file mode 100644 index 0000000..cfd8ae0 --- /dev/null +++ b/src/app/modules/pos/components/products/categories.component.html @@ -0,0 +1,29 @@ +
+ @if (loading()) { + @for (i of [1, 2, 3, 4, 5]; track i) { + + } + } @else { + @for (category of categories(); track category.id) { +
+ {{ category.name }} +
+ + {{ category.productCount }} + +
+
+ } + } +
diff --git a/src/app/modules/pos/components/products/categories.component.ts b/src/app/modules/pos/components/products/categories.component.ts new file mode 100644 index 0000000..2dda60d --- /dev/null +++ b/src/app/modules/pos/components/products/categories.component.ts @@ -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(); + + constructor(private service: PosService) { + this.getCategories(); + } + + categories = signal>(null); + loading = signal(true); + + selectedCategory = signal>(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); + }, + }); + } +} diff --git a/src/app/modules/pos/components/products/grid-view.component.html b/src/app/modules/pos/components/products/grid-view.component.html new file mode 100644 index 0000000..b0af442 --- /dev/null +++ b/src/app/modules/pos/components/products/grid-view.component.html @@ -0,0 +1,25 @@ +
+ @if (loading()) { + @for (_ of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15]; track $index) { +
+ +
+ } + } @else { + @for (product of products(); track product.id) { +
+ +
+ + {{ product.product.name }} + ({{ product.quantity }}) + +
+ + +
+
+
+ } + } +
diff --git a/src/app/modules/pos/components/products/grid-view.component.ts b/src/app/modules/pos/components/products/grid-view.component.ts new file mode 100644 index 0000000..6551c94 --- /dev/null +++ b/src/app/modules/pos/components/products/grid-view.component.ts @@ -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); + } +} diff --git a/src/app/modules/pos/components/products/products.component.html b/src/app/modules/pos/components/products/products.component.html new file mode 100644 index 0000000..ed838e8 --- /dev/null +++ b/src/app/modules/pos/components/products/products.component.html @@ -0,0 +1,12 @@ +
+
+
+ + لیست کالاها +
+ +
+ + + +
diff --git a/src/app/modules/pos/components/products/products.component.ts b/src/app/modules/pos/components/products/products.component.ts new file mode 100644 index 0000000..cc156ff --- /dev/null +++ b/src/app/modules/pos/components/products/products.component.ts @@ -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>(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); + } +} diff --git a/src/app/modules/pos/constants/apiRoutes/index.ts b/src/app/modules/pos/constants/apiRoutes/index.ts index 69bd806..1eb2500 100644 --- a/src/app/modules/pos/constants/apiRoutes/index.ts +++ b/src/app/modules/pos/constants/apiRoutes/index.ts @@ -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`, +}; diff --git a/src/app/modules/pos/constants/index.ts b/src/app/modules/pos/constants/index.ts index fdb1998..ee61bd7 100644 --- a/src/app/modules/pos/constants/index.ts +++ b/src/app/modules/pos/constants/index.ts @@ -1,2 +1,2 @@ -// export * from './apiRoutes'; +export * from './apiRoutes'; export * from './routes'; diff --git a/src/app/modules/pos/models/index.ts b/src/app/modules/pos/models/index.ts new file mode 100644 index 0000000..61a518a --- /dev/null +++ b/src/app/modules/pos/models/index.ts @@ -0,0 +1 @@ +export * from './io'; diff --git a/src/app/modules/pos/models/io.ts b/src/app/modules/pos/models/io.ts new file mode 100644 index 0000000..7d6a51a --- /dev/null +++ b/src/app/modules/pos/models/io.ts @@ -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[]; +} diff --git a/src/app/modules/pos/models/types.ts b/src/app/modules/pos/models/types.ts new file mode 100644 index 0000000..6c98c71 --- /dev/null +++ b/src/app/modules/pos/models/types.ts @@ -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; +} diff --git a/src/app/modules/pos/services/main.service.ts b/src/app/modules/pos/services/main.service.ts new file mode 100644 index 0000000..36587a7 --- /dev/null +++ b/src/app/modules/pos/services/main.service.ts @@ -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 { + return this.http.get(this.apiRoutes.info()); + } + + getStock(): Observable> { + return this.http.get>(this.apiRoutes.stock()); + } + + getCategories(): Observable> { + return this.http.get>( + this.apiRoutes.productCategories(), + ); + } +} diff --git a/src/app/modules/pos/store/index.ts b/src/app/modules/pos/store/index.ts new file mode 100644 index 0000000..fb750a7 --- /dev/null +++ b/src/app/modules/pos/store/index.ts @@ -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; + getInfoLoading: boolean; + info: Maybe; + getProductCategoriesLoading: boolean; + productCategories: Maybe; + activeProductCategory: Maybe; + 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({ ...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) { + this.state$.set({ ...this.state$(), ...partial }); + } + + reset() { + this.state$.set({ ...INITIAL_POS_STATE }); + } + + initial() { + this.getInfo(); + this.getStock(); + this.getProductCategories(); + } + + fillInitial(data: Partial) { + 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: [] }); + } +} diff --git a/src/app/modules/pos/views/pos.component.html b/src/app/modules/pos/views/pos.component.html index 7144e26..2b1257e 100644 --- a/src/app/modules/pos/views/pos.component.html +++ b/src/app/modules/pos/views/pos.component.html @@ -1 +1,26 @@ -
+
+
+
+
+ Logo +
+ {{ info()?.store?.name }} +
+
+
+ +
+
+ فروشنده شماره‌ی ۱ +
+
+
+
+
+ +
+
+ +
+
+
diff --git a/src/app/modules/pos/views/pos.component.ts b/src/app/modules/pos/views/pos.component.ts index 07e9eec..ff52803 100644 --- a/src/app/modules/pos/views/pos.component.ts +++ b/src/app/modules/pos/views/pos.component.ts @@ -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>(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); + }, + }); + } } diff --git a/src/app/modules/productBrands/views/list.component.ts b/src/app/modules/productBrands/views/list.component.ts index f6f39f7..c60375e 100644 --- a/src/app/modules/productBrands/views/list.component.ts +++ b/src/app/modules/productBrands/views/list.component.ts @@ -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); }); diff --git a/src/app/modules/products/components/fullForm/full-form.component.html b/src/app/modules/products/components/fullForm/full-form.component.html index c3e1e6c..7c7547f 100644 --- a/src/app/modules/products/components/fullForm/full-form.component.html +++ b/src/app/modules/products/components/fullForm/full-form.component.html @@ -41,7 +41,7 @@
- +
diff --git a/src/app/modules/products/components/fullForm/full-form.component.ts b/src/app/modules/products/components/fullForm/full-form.component.ts index 3572105..403c4e2 100644 --- a/src/app/modules/products/components/fullForm/full-form.component.ts +++ b/src/app/modules/products/components/fullForm/full-form.component.ts @@ -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() { diff --git a/src/app/modules/products/models/io.d.ts b/src/app/modules/products/models/io.d.ts index 8fdfb6c..692192c 100644 --- a/src/app/modules/products/models/io.d.ts +++ b/src/app/modules/products/models/io.d.ts @@ -11,7 +11,7 @@ export interface IProductRawResponse { brand?: IProductBrand; category?: IProductCategory; // supplierId: number; - basePrice?: string; + salePrice?: string; count?: number; createdAt: Date; updatedAt: Date; diff --git a/src/app/modules/products/views/list.component.ts b/src/app/modules/products/views/list.component.ts index 299d1ad..b245d91 100644 --- a/src/app/modules/products/views/list.component.ts +++ b/src/app/modules/products/views/list.component.ts @@ -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[]; diff --git a/src/app/modules/products/views/single.component.ts b/src/app/modules/products/views/single.component.ts index b882168..1b79e6a 100644 --- a/src/app/modules/products/views/single.component.ts +++ b/src/app/modules/products/views/single.component.ts @@ -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', diff --git a/src/app/modules/purchases/components/form.component.ts b/src/app/modules/purchases/components/form.component.ts index 0255f24..2b832d4 100644 --- a/src/app/modules/purchases/components/form.component.ts +++ b/src/app/modules/purchases/components/form.component.ts @@ -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; diff --git a/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts b/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts index a581d6f..0395fd0 100644 --- a/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts +++ b/src/app/modules/purchases/components/receiptTemplate/purchase-receipt-template.component.ts @@ -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({ diff --git a/src/app/modules/users/views/list.component.ts b/src/app/modules/users/views/list.component.ts index 5985482..89f02f9 100644 --- a/src/app/modules/users/views/list.component.ts +++ b/src/app/modules/users/views/list.component.ts @@ -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); }); diff --git a/src/app/shared/components/detailsInfoCard/details-info-card.component.html b/src/app/shared/components/detailsInfoCard/details-info-card.component.html index dbe46e8..31f6b80 100644 --- a/src/app/shared/components/detailsInfoCard/details-info-card.component.html +++ b/src/app/shared/components/detailsInfoCard/details-info-card.component.html @@ -1,10 +1,10 @@ -
+
- {{ label }} + {{ label }} {{ value }}
diff --git a/src/app/shared/components/pageDataList/page-data-list.component.ts b/src/app/shared/components/pageDataList/page-data-list.component.ts index 2fdb930..802e52c 100644 --- a/src/app/shared/components/pageDataList/page-data-list.component.ts +++ b/src/app/shared/components/pageDataList/page-data-list.component.ts @@ -1,5 +1,5 @@ import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit'; -import { formatJalali } from '@/utils'; +import { formatJalali, formatWithCurrency } from '@/utils'; import { CommonModule } from '@angular/common'; import { Component, @@ -129,7 +129,7 @@ export class PageDataListComponent { case 'boolean': return data ? 'بله' : 'خیر'; case 'price': - return typeof data === 'number' ? data.toLocaleString() : '-'; + return formatWithCurrency(data, false, 'ریال'); default: break; } diff --git a/src/app/shared/directives/price-mask.directive.ts b/src/app/shared/directives/price-mask.directive.ts index 677a311..852fbe6 100644 --- a/src/app/shared/directives/price-mask.directive.ts +++ b/src/app/shared/directives/price-mask.directive.ts @@ -11,6 +11,12 @@ import { SimpleChanges, } from '@angular/core'; import { NgControl } from '@angular/forms'; +import { + cleanNumericString, + formatNumber, + formatWithCurrency, + normalizeToNumber, +} from '../../utils/price-mask.utils'; @Directive({ selector: '[appPriceMask]', @@ -57,78 +63,13 @@ export class PriceMaskDirective implements OnInit, OnChanges { } } - private toArabicDigitsAwareNumber(str: string): string { - if (!str) return ''; - // Map Arabic-Indic and Extended Arabic-Indic digits to ASCII digits - const map: Record = { - '٠': '0', - '١': '1', - '٢': '2', - '٣': '3', - '٤': '4', - '٥': '5', - '٦': '6', - '٧': '7', - '٨': '8', - '٩': '9', - '۰': '0', - '۱': '1', - '۲': '2', - '۳': '3', - '۴': '4', - '۵': '5', - '۶': '6', - '۷': '7', - '۸': '8', - '۹': '9', - }; - return str.replace(/[٠-٩۰-۹]/g, (d) => map[d] ?? d); - } - - private cleanNumericString(raw: string): string { - // Convert localized digits to ASCII, then remove everything except digits, dot and minus - const ascii = this.toArabicDigitsAwareNumber(raw); - return ascii.replace(/[^0-9.\-]/g, ''); - } - - private normalizeToNumber(raw: number | string | null | undefined): number | null { - if (raw === null || raw === undefined) return null; - if (typeof raw === 'number') return isFinite(raw) ? raw : null; - const cleaned = this.cleanNumericString(String(raw)); - if (cleaned === '') return null; - const num = Number(cleaned); - return isNaN(num) ? null : num; - } - - private formatNumber(value: number): string { - try { - const fmtLocale = this.useComma ? 'en-US' : this.locale; - return new Intl.NumberFormat(fmtLocale, { - maximumFractionDigits: this.fraction, - minimumFractionDigits: 0, - useGrouping: true, - }).format(value); - } catch (e) { - return String(value); - } - } - - private formatWithCurrency(num: number | null, isInputHost: boolean): string { - if (num === null) return ''; - const formatted = this.formatNumber(num); - // For inputs we should NOT append currency to avoid polluting numeric entry - if (isInputHost) return formatted; - if (this.currency && this.currency.trim()) { - return `${formatted} ${this.currency.trim()}`; - } - return formatted; - } + // Numeric parsing/formatting helpers moved to utils/price-mask.utils.ts @HostListener('input', ['$event']) onInput(ev: Event) { if (!this.inputEl) return; const raw = (ev.target as HTMLInputElement).value || this.inputEl.value || ''; - const cleaned = this.cleanNumericString(raw); + const cleaned = cleanNumericString(raw); if (cleaned === '') { // clear control if (this.ngControl?.control) this.ngControl.control.setValue(null); @@ -137,7 +78,11 @@ export class PriceMaskDirective implements OnInit, OnChanges { const asNumber = Number(cleaned); if (isNaN(asNumber)) return; - const formatted = this.formatNumber(asNumber); + const formatted = formatNumber(asNumber, { + locale: this.locale, + useComma: this.useComma, + fraction: this.fraction, + }); // preserve caret position roughly: compute delta and adjust const prevPos = this.inputEl.selectionStart ?? raw.length; @@ -168,7 +113,7 @@ export class PriceMaskDirective implements OnInit, OnChanges { /** Render when bound via [appPriceMask] on non-input hosts (e.g., span) or to set initial value. */ private renderFromBoundValue() { - const num = this.normalizeToNumber(this.priceValue); + const num = normalizeToNumber(this.priceValue); // If host has an input element, set both control value (if any) and displayed value if (this.inputEl) { @@ -180,13 +125,21 @@ export class PriceMaskDirective implements OnInit, OnChanges { } } - const formatted = this.formatWithCurrency(num, true); + const formatted = formatWithCurrency(num, true, this.currency, { + locale: this.locale, + useComma: this.useComma, + fraction: this.fraction, + }); this.renderer.setProperty(this.inputEl, 'value', formatted); return; } // Otherwise, render to host text (e.g., span/div) - const formatted = this.formatWithCurrency(num, false); + const formatted = formatWithCurrency(num, false, this.currency, { + locale: this.locale, + useComma: this.useComma, + fraction: this.fraction, + }); this.renderer.setProperty(this.el.nativeElement, 'textContent', formatted); } } diff --git a/src/app/uikit/uikit-counter.component.html b/src/app/uikit/uikit-counter.component.html index a651bf2..95cf754 100644 --- a/src/app/uikit/uikit-counter.component.html +++ b/src/app/uikit/uikit-counter.component.html @@ -1,35 +1,38 @@ -
+
+ icon="pi pi-plus" + aria-label="increase" + size="small" + class="shrink-0" + outlined + (click)="increase()" + > - + icon="pi pi-minus" + aria-label="decrease" + size="small" + class="shrink-0" + outlined + (click)="decrease()" + >
diff --git a/src/app/uikit/uikit-counter.component.ts b/src/app/uikit/uikit-counter.component.ts index e748a10..5f9476f 100644 --- a/src/app/uikit/uikit-counter.component.ts +++ b/src/app/uikit/uikit-counter.component.ts @@ -1,22 +1,24 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, Input, model } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; +import { InputNumber } from 'primeng/inputnumber'; @Component({ selector: 'app-uikit-counter', templateUrl: './uikit-counter.component.html', styleUrls: ['./uikit-counter.component.scss'], standalone: true, - imports: [CommonModule, ButtonModule], + imports: [CommonModule, ButtonModule, FormsModule, ReactiveFormsModule, FormsModule, InputNumber], }) export class UikitCounterComponent { - @Input() value = 0; + @Input() control = new FormControl(0); @Input() min: number | null = null; @Input() max: number | null = null; @Input() step = 1; @Input() disabled = false; - @Output() valueChange = new EventEmitter(); + value = model(0); clamp(v: number): number { if (this.min !== null && v < this.min) return this.min; @@ -26,20 +28,20 @@ export class UikitCounterComponent { setValue(v: number) { const clamped = this.clamp(v); - if (clamped !== this.value) { - this.value = clamped; - this.valueChange.emit(this.value); + if (clamped !== this.value()) { + this.control?.setValue(clamped); + this.value.set(clamped); } } increase() { if (this.disabled) return; - this.setValue(this.value + this.step); + this.setValue(this.value() + this.step); } decrease() { if (this.disabled) return; - this.setValue(this.value - this.step); + this.setValue(this.value() - this.step); } onInput(e: Event) { diff --git a/src/app/utils/index.ts b/src/app/utils/index.ts index af2e5ff..b3d1f7f 100644 --- a/src/app/utils/index.ts +++ b/src/app/utils/index.ts @@ -1,4 +1,5 @@ export * from './currency'; export * from './jalali-date.utils'; export * from './name'; +export * from './price-mask.utils'; export * from './time'; diff --git a/src/app/utils/jalali-date.utils.ts b/src/app/utils/jalali-date.utils.ts index 557b4f6..c961902 100644 --- a/src/app/utils/jalali-date.utils.ts +++ b/src/app/utils/jalali-date.utils.ts @@ -13,7 +13,7 @@ export function fromJalali(jalaliDate: string): Dayjs { } export function formatJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): string { - return toJalali(date).format(format); + return toJalali(date).locale('fa').format(format); } export function jalaliDiff( diff --git a/src/app/utils/price-mask.utils.ts b/src/app/utils/price-mask.utils.ts new file mode 100644 index 0000000..d132c40 --- /dev/null +++ b/src/app/utils/price-mask.utils.ts @@ -0,0 +1,77 @@ +export function toArabicDigitsAwareNumber(str: string): string { + if (!str) return ''; + const map: Record = { + '٠': '0', + '١': '1', + '٢': '2', + '٣': '3', + '٤': '4', + '٥': '5', + '٦': '6', + '٧': '7', + '٨': '8', + '٩': '9', + '۰': '0', + '۱': '1', + '۲': '2', + '۳': '3', + '۴': '4', + '۵': '5', + '۶': '6', + '۷': '7', + '۸': '8', + '۹': '9', + }; + return str.replace(/[٠-٩۰-۹]/g, (d) => map[d] ?? d); +} + +export function cleanNumericString(raw: string): string { + const ascii = toArabicDigitsAwareNumber(raw || ''); + return ascii.replace(/[^0-9.\-]/g, ''); +} + +export function normalizeToNumber(raw: number | string | null | undefined): number | null { + if (raw === null || raw === undefined) return null; + if (typeof raw === 'number') return isFinite(raw) ? raw : null; + const cleaned = cleanNumericString(String(raw)); + if (cleaned === '') return null; + const num = Number(cleaned); + return isNaN(num) ? null : num; +} + +export function formatNumber( + value: number, + opts?: { locale?: string; useComma?: boolean; fraction?: number }, +): string { + try { + const fmtLocale = opts?.useComma ? 'en-US' : (opts?.locale ?? 'fa-IR'); + return new Intl.NumberFormat(fmtLocale, { + maximumFractionDigits: opts?.fraction ?? 0, + minimumFractionDigits: 0, + useGrouping: true, + }).format(value); + } catch (e) { + return String(value); + } +} + +export function formatWithCurrency( + num: number | string | null, + isInputHost: boolean = false, + currency: string | null | undefined = 'ریال', + opts?: { locale?: string; useComma?: boolean; fraction?: number }, +): string { + if (num === null) return ''; + const formatted = formatNumber(parseFloat(num + ''), opts); + if (isInputHost) return formatted; + if (currency && currency.trim()) return `${formatted} ${currency.trim()}`; + return formatted; +} + +export default { + toArabicDigitsAwareNumber, + cleanNumericString, + normalizeToNumber, + formatNumber, + formatWithCurrency, +}; diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 638f57b..3547e95 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -3,9 +3,11 @@ import errors from './errors'; import login from './login.webp'; import logo from './logo.png'; +import { placeholders } from './placeholders'; export default { logo, login, errors, + placeholders, }; diff --git a/src/assets/images/placeholders/index.ts b/src/assets/images/placeholders/index.ts new file mode 100644 index 0000000..2202d71 --- /dev/null +++ b/src/assets/images/placeholders/index.ts @@ -0,0 +1,7 @@ +import logo from './logoPlaceholder.png'; +import productPlaceholder from './productPlaceholder.png'; + +export const placeholders = { + logo, + default: productPlaceholder, +}; diff --git a/src/assets/images/placeholders/logoPlaceholder.png b/src/assets/images/placeholders/logoPlaceholder.png new file mode 100644 index 0000000..cb59239 Binary files /dev/null and b/src/assets/images/placeholders/logoPlaceholder.png differ diff --git a/src/assets/images/placeholders/productPlaceholder.png b/src/assets/images/placeholders/productPlaceholder.png new file mode 100644 index 0000000..6c6877a Binary files /dev/null and b/src/assets/images/placeholders/productPlaceholder.png differ diff --git a/src/assets/tailwind.css b/src/assets/tailwind.css index 8129b61..3919836 100644 --- a/src/assets/tailwind.css +++ b/src/assets/tailwind.css @@ -35,3 +35,10 @@ --color-error: var(--p-red-500); --p-inputtext-padding-y: 1rem !important; } + +@layer utilities { + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + @apply appearance-none; + } +}