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() {
|
getAll() {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
console.log(this.inventoryId);
|
|
||||||
|
|
||||||
return this.service.getMovements(this.inventoryId, this.movementType).subscribe({
|
return this.service.getMovements(this.inventoryId, this.movementType).subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
|
|||||||
@@ -92,13 +92,10 @@ export class InventoriesTransferFormComponent {
|
|||||||
count: [1, [Validators.required, Validators.min(1)]],
|
count: [1, [Validators.required, Validators.min(1)]],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(this.form.controls.items);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelectedProductsInForm($e: TableRowUnSelectEvent<IInventoryProduct>) {
|
clearSelectedProductsInForm($e: TableRowUnSelectEvent<IInventoryProduct>) {
|
||||||
console.log($e.index);
|
|
||||||
if ($e.index != null) {
|
if ($e.index != null) {
|
||||||
this.form.controls.items.removeAt($e.index);
|
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 = {
|
export const POS_API_ROUTES = {
|
||||||
// list: () => `${baseUrl}`,
|
info: () => `${baseUrl}`,
|
||||||
// single: (categoryId: string) => `${baseUrl}/${categoryId}`,
|
stock: () => `${baseUrl}/stock`,
|
||||||
// };
|
productCategories: () => `${baseUrl}/product-categories`,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// export * from './apiRoutes';
|
export * from './apiRoutes';
|
||||||
export * from './routes';
|
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({
|
@Component({
|
||||||
selector: 'app-pos',
|
selector: 'app-pos',
|
||||||
templateUrl: './pos.component.html',
|
templateUrl: './pos.component.html',
|
||||||
|
imports: [JalaliDateDirective, PosProductsComponent, PosOrderCardComponent],
|
||||||
})
|
})
|
||||||
export class POSComponent {
|
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() {
|
getData() {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.productBrandService.getAll().subscribe((res) => {
|
this.productBrandService.getAll().subscribe((res) => {
|
||||||
console.log(res);
|
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.items.set(res.data);
|
this.items.set(res.data);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<div class="flex flex-col gap-3 grow">
|
<div class="flex flex-col gap-3 grow">
|
||||||
<p-card header="قیمت">
|
<p-card header="قیمت">
|
||||||
<form [formGroup]="form" (submit)="submit()" class="flex flex-col gap-4">
|
<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>
|
</form>
|
||||||
</p-card>
|
</p-card>
|
||||||
<p-card header="اطلاعات تکمیلی">
|
<p-card header="اطلاعات تکمیلی">
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class ProductFullFormComponent {
|
|||||||
description: [this.initialData?.description || null],
|
description: [this.initialData?.description || null],
|
||||||
categoryId: [this.initialData?.category?.id || null],
|
categoryId: [this.initialData?.category?.id || null],
|
||||||
imageUrl: [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() {
|
get backRoute() {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ export interface IProductRawResponse {
|
|||||||
brand?: IProductBrand;
|
brand?: IProductBrand;
|
||||||
category?: IProductCategory;
|
category?: IProductCategory;
|
||||||
// supplierId: number;
|
// supplierId: number;
|
||||||
basePrice?: string;
|
salePrice?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ProductsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
{ field: 'id', header: 'شناسه' },
|
{ field: 'sku', header: 'شناسه' },
|
||||||
{ field: 'name', header: 'نام' },
|
{ field: 'name', header: 'نام' },
|
||||||
{
|
{
|
||||||
field: 'brand',
|
field: 'brand',
|
||||||
@@ -39,6 +39,11 @@ export class ProductsComponent {
|
|||||||
return item.category?.name || '-';
|
return item.category?.name || '-';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'salePrice',
|
||||||
|
header: 'قیمت فروش',
|
||||||
|
type: 'price',
|
||||||
|
},
|
||||||
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
|
{ field: 'createdAt', header: 'تاریخ ایجاد', type: 'date' },
|
||||||
{ field: 'updatedAt', header: 'تاریخ بهروزرسانی', type: 'date' },
|
{ field: 'updatedAt', header: 'تاریخ بهروزرسانی', type: 'date' },
|
||||||
] as IColumn[];
|
] as IColumn[];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Maybe } from '@/core';
|
|||||||
import { PurchaseFormComponent } from '@/modules/purchases/components/form.component';
|
import { PurchaseFormComponent } from '@/modules/purchases/components/form.component';
|
||||||
import { KeyValueComponent } from '@/shared/components';
|
import { KeyValueComponent } from '@/shared/components';
|
||||||
import { DetailsInfoCardComponent } from '@/shared/components/detailsInfoCard/details-info-card.component';
|
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 { Component, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { Button, ButtonDirective } from 'primeng/button';
|
import { Button, ButtonDirective } from 'primeng/button';
|
||||||
@@ -86,12 +87,17 @@ export class ProductComponent {
|
|||||||
{
|
{
|
||||||
icon: 'pi pi-dollar',
|
icon: 'pi pi-dollar',
|
||||||
label: 'قیمت پایه',
|
label: 'قیمت پایه',
|
||||||
value: this.product()?.basePrice?.toString() || 'تعیین نشده',
|
value: this.product()?.salePrice
|
||||||
|
? priceMaskUtils.formatWithCurrency(this.product()!.salePrice!)
|
||||||
|
: 'تعیین نشده',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'pi pi-dollar',
|
icon: 'pi pi-dollar',
|
||||||
label: 'میانگین قیمت',
|
label: 'میانگین قیمت خرید',
|
||||||
value: this.product()?.avgCost?.toString() || 'تعیین نشده',
|
value: this.product()?.salePrice
|
||||||
|
? priceMaskUtils.formatWithCurrency(this.product()!.avgCost!)
|
||||||
|
: 'تعیین نشده',
|
||||||
|
type: 'price',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'pi pi-clock',
|
icon: 'pi pi-clock',
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ export class PurchaseFormComponent {
|
|||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
console.log(this.form);
|
|
||||||
console.log(this.form.value);
|
|
||||||
|
|
||||||
if (this.form.valid) {
|
if (this.form.valid) {
|
||||||
this.form.disable();
|
this.form.disable();
|
||||||
const data = this.form.value as IPurchaseRequest;
|
const data = this.form.value as IPurchaseRequest;
|
||||||
|
|||||||
-1
@@ -172,7 +172,6 @@ export class PurchaseReceiptTemplateComponent {
|
|||||||
return { ...res, productId: item.product?.id };
|
return { ...res, productId: item.product?.id };
|
||||||
}),
|
}),
|
||||||
} as IPurchaseRequest;
|
} as IPurchaseRequest;
|
||||||
console.log(payload);
|
|
||||||
this.form.disable();
|
this.form.disable();
|
||||||
|
|
||||||
this.service.create(payload).subscribe({
|
this.service.create(payload).subscribe({
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ export class UsersComponent {
|
|||||||
getData() {
|
getData() {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.userService.getAll().subscribe((res) => {
|
this.userService.getAll().subscribe((res) => {
|
||||||
console.log(res);
|
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.items.set(res.data);
|
this.items.set(res.data);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p-card class="w-full border border-primary-700">
|
<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">
|
<div class="shrink-0 pt-1">
|
||||||
<i [class]="'pi ' + icon + ' text-2xl'"></i>
|
<i [class]="'pi ' + icon + ' text-2xl'"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow flex flex-col gap-1">
|
<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>
|
<span class="text-xl font-bold text-primary">{{ value }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit';
|
import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit';
|
||||||
import { formatJalali } from '@/utils';
|
import { formatJalali, formatWithCurrency } from '@/utils';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -129,7 +129,7 @@ export class PageDataListComponent<I> {
|
|||||||
case 'boolean':
|
case 'boolean':
|
||||||
return data ? 'بله' : 'خیر';
|
return data ? 'بله' : 'خیر';
|
||||||
case 'price':
|
case 'price':
|
||||||
return typeof data === 'number' ? data.toLocaleString() : '-';
|
return formatWithCurrency(data, false, 'ریال');
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgControl } from '@angular/forms';
|
import { NgControl } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
cleanNumericString,
|
||||||
|
formatNumber,
|
||||||
|
formatWithCurrency,
|
||||||
|
normalizeToNumber,
|
||||||
|
} from '../../utils/price-mask.utils';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[appPriceMask]',
|
selector: '[appPriceMask]',
|
||||||
@@ -57,78 +63,13 @@ export class PriceMaskDirective implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toArabicDigitsAwareNumber(str: string): string {
|
// Numeric parsing/formatting helpers moved to utils/price-mask.utils.ts
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('input', ['$event'])
|
@HostListener('input', ['$event'])
|
||||||
onInput(ev: Event) {
|
onInput(ev: Event) {
|
||||||
if (!this.inputEl) return;
|
if (!this.inputEl) return;
|
||||||
const raw = (ev.target as HTMLInputElement).value || this.inputEl.value || '';
|
const raw = (ev.target as HTMLInputElement).value || this.inputEl.value || '';
|
||||||
const cleaned = this.cleanNumericString(raw);
|
const cleaned = cleanNumericString(raw);
|
||||||
if (cleaned === '') {
|
if (cleaned === '') {
|
||||||
// clear control
|
// clear control
|
||||||
if (this.ngControl?.control) this.ngControl.control.setValue(null);
|
if (this.ngControl?.control) this.ngControl.control.setValue(null);
|
||||||
@@ -137,7 +78,11 @@ export class PriceMaskDirective implements OnInit, OnChanges {
|
|||||||
const asNumber = Number(cleaned);
|
const asNumber = Number(cleaned);
|
||||||
if (isNaN(asNumber)) return;
|
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
|
// preserve caret position roughly: compute delta and adjust
|
||||||
const prevPos = this.inputEl.selectionStart ?? raw.length;
|
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. */
|
/** Render when bound via [appPriceMask] on non-input hosts (e.g., span) or to set initial value. */
|
||||||
private renderFromBoundValue() {
|
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 host has an input element, set both control value (if any) and displayed value
|
||||||
if (this.inputEl) {
|
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);
|
this.renderer.setProperty(this.inputEl, 'value', formatted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, render to host text (e.g., span/div)
|
// 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);
|
this.renderer.setProperty(this.el.nativeElement, 'textContent', formatted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
pButton
|
pButton
|
||||||
class="p-button p-component flex items-center justify-center px-3"
|
|
||||||
(click)="decrease()"
|
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
aria-label="decrease"
|
icon="pi pi-plus"
|
||||||
>
|
aria-label="increase"
|
||||||
-
|
size="small"
|
||||||
</button>
|
class="shrink-0"
|
||||||
|
outlined
|
||||||
|
(click)="increase()"
|
||||||
|
></button>
|
||||||
|
|
||||||
<input
|
<p-inputnumber
|
||||||
class="text-center w-16 px-2 py-1 outline-none"
|
|
||||||
type="number"
|
type="number"
|
||||||
[value]="value"
|
[formControl]="control"
|
||||||
(input)="onInput($event)"
|
[min]="min"
|
||||||
[min]="min ?? undefined"
|
[max]="max"
|
||||||
[max]="max ?? undefined"
|
|
||||||
[step]="step"
|
[step]="step"
|
||||||
|
size="small"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
|
[(ngModel)]="value"
|
||||||
aria-label="quantity"
|
aria-label="quantity"
|
||||||
|
inputStyleClass="text-center! outline-none border-none! grow w-12 px-1! appearance-none!"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
pButton
|
pButton
|
||||||
class="p-button p-component flex items-center justify-center px-3"
|
|
||||||
(click)="increase()"
|
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
aria-label="increase"
|
icon="pi pi-minus"
|
||||||
>
|
aria-label="decrease"
|
||||||
+
|
size="small"
|
||||||
</button>
|
class="shrink-0"
|
||||||
|
outlined
|
||||||
|
(click)="decrease()"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
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 { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputNumber } from 'primeng/inputnumber';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-uikit-counter',
|
selector: 'app-uikit-counter',
|
||||||
templateUrl: './uikit-counter.component.html',
|
templateUrl: './uikit-counter.component.html',
|
||||||
styleUrls: ['./uikit-counter.component.scss'],
|
styleUrls: ['./uikit-counter.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ButtonModule],
|
imports: [CommonModule, ButtonModule, FormsModule, ReactiveFormsModule, FormsModule, InputNumber],
|
||||||
})
|
})
|
||||||
export class UikitCounterComponent {
|
export class UikitCounterComponent {
|
||||||
@Input() value = 0;
|
@Input() control = new FormControl<number>(0);
|
||||||
@Input() min: number | null = null;
|
@Input() min: number | null = null;
|
||||||
@Input() max: number | null = null;
|
@Input() max: number | null = null;
|
||||||
@Input() step = 1;
|
@Input() step = 1;
|
||||||
@Input() disabled = false;
|
@Input() disabled = false;
|
||||||
|
|
||||||
@Output() valueChange = new EventEmitter<number>();
|
value = model(0);
|
||||||
|
|
||||||
clamp(v: number): number {
|
clamp(v: number): number {
|
||||||
if (this.min !== null && v < this.min) return this.min;
|
if (this.min !== null && v < this.min) return this.min;
|
||||||
@@ -26,20 +28,20 @@ export class UikitCounterComponent {
|
|||||||
|
|
||||||
setValue(v: number) {
|
setValue(v: number) {
|
||||||
const clamped = this.clamp(v);
|
const clamped = this.clamp(v);
|
||||||
if (clamped !== this.value) {
|
if (clamped !== this.value()) {
|
||||||
this.value = clamped;
|
this.control?.setValue(clamped);
|
||||||
this.valueChange.emit(this.value);
|
this.value.set(clamped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
increase() {
|
increase() {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
this.setValue(this.value + this.step);
|
this.setValue(this.value() + this.step);
|
||||||
}
|
}
|
||||||
|
|
||||||
decrease() {
|
decrease() {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
this.setValue(this.value - this.step);
|
this.setValue(this.value() - this.step);
|
||||||
}
|
}
|
||||||
|
|
||||||
onInput(e: Event) {
|
onInput(e: Event) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './currency';
|
export * from './currency';
|
||||||
export * from './jalali-date.utils';
|
export * from './jalali-date.utils';
|
||||||
export * from './name';
|
export * from './name';
|
||||||
|
export * from './price-mask.utils';
|
||||||
export * from './time';
|
export * from './time';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function fromJalali(jalaliDate: string): Dayjs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): string {
|
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(
|
export function jalaliDiff(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import errors from './errors';
|
import errors from './errors';
|
||||||
import login from './login.webp';
|
import login from './login.webp';
|
||||||
import logo from './logo.png';
|
import logo from './logo.png';
|
||||||
|
import { placeholders } from './placeholders';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
logo,
|
logo,
|
||||||
login,
|
login,
|
||||||
errors,
|
errors,
|
||||||
|
placeholders,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 |
@@ -35,3 +35,10 @@
|
|||||||
--color-error: var(--p-red-500);
|
--color-error: var(--p-red-500);
|
||||||
--p-inputtext-padding-y: 1rem !important;
|
--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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user