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:
2025-12-14 20:34:15 +03:30
parent 35be7e0298
commit 17fa65407c
43 changed files with 894 additions and 132 deletions
@@ -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 -1
View File
@@ -1,2 +1,2 @@
// export * from './apiRoutes';
export * from './apiRoutes';
export * from './routes';
+1
View File
@@ -0,0 +1 @@
export * from './io';
+30
View File
@@ -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[];
}
+23
View File
@@ -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(),
);
}
}
+218
View File
@@ -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: [] });
}
}
+26 -1
View File
@@ -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>
+33 -2
View File
@@ -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
View File
@@ -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;
@@ -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);
});
@@ -1,10 +1,10 @@
<p-card class="w-full border border-primary-700">
<div class="flex gap-5 text-primary-400">
<div class="flex gap-3 text-primary-400">
<div class="shrink-0 pt-1">
<i [class]="'pi ' + icon + ' text-2xl'"></i>
</div>
<div class="grow flex flex-col gap-1">
<span class="text-sm">{{ label }}</span>
<span class="text-sm font-bold">{{ label }}</span>
<span class="text-xl font-bold text-primary">{{ value }}</span>
</div>
</div>
@@ -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<I> {
case 'boolean':
return data ? 'بله' : 'خیر';
case 'price':
return typeof data === 'number' ? data.toLocaleString() : '-';
return formatWithCurrency(data, false, 'ریال');
default:
break;
}
@@ -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<string, string> = {
'٠': '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);
}
}
+22 -19
View File
@@ -1,35 +1,38 @@
<div class="flex items-center border rounded overflow-hidden bg-white" [class.opacity-60]="disabled">
<div class="flex items-center w-full" [class.opacity-60]="disabled">
<button
type="button"
pButton
class="p-button p-component flex items-center justify-center px-3"
(click)="decrease()"
[disabled]="disabled"
aria-label="decrease"
>
-
</button>
icon="pi pi-plus"
aria-label="increase"
size="small"
class="shrink-0"
outlined
(click)="increase()"
></button>
<input
class="text-center w-16 px-2 py-1 outline-none"
<p-inputnumber
type="number"
[value]="value"
(input)="onInput($event)"
[min]="min ?? undefined"
[max]="max ?? undefined"
[formControl]="control"
[min]="min"
[max]="max"
[step]="step"
size="small"
[disabled]="disabled"
[(ngModel)]="value"
aria-label="quantity"
inputStyleClass="text-center! outline-none border-none! grow w-12 px-1! appearance-none!"
/>
<button
type="button"
pButton
class="p-button p-component flex items-center justify-center px-3"
(click)="increase()"
[disabled]="disabled"
aria-label="increase"
>
+
</button>
icon="pi pi-minus"
aria-label="decrease"
size="small"
class="shrink-0"
outlined
(click)="decrease()"
></button>
</div>
+11 -9
View File
@@ -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<number>(0);
@Input() min: number | null = null;
@Input() max: number | null = null;
@Input() step = 1;
@Input() disabled = false;
@Output() valueChange = new EventEmitter<number>();
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) {
+1
View File
@@ -1,4 +1,5 @@
export * from './currency';
export * from './jalali-date.utils';
export * from './name';
export * from './price-mask.utils';
export * from './time';
+1 -1
View File
@@ -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(
+77
View File
@@ -0,0 +1,77 @@
export function toArabicDigitsAwareNumber(str: string): string {
if (!str) return '';
const map: Record<string, string> = {
'٠': '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,
};
+2
View File
@@ -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,
};
+7
View File
@@ -0,0 +1,7 @@
import logo from './logoPlaceholder.png';
import productPlaceholder from './productPlaceholder.png';
export const placeholders = {
logo,
default: productPlaceholder,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+7
View File
@@ -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;
}
}