update pos consumer

This commit is contained in:
2026-03-29 18:07:10 +03:30
parent 1e2f94261e
commit c10623bc3f
86 changed files with 2935 additions and 385 deletions
@@ -7,8 +7,6 @@ import { environment } from 'src/environments/environment';
@Injectable()
export class ApiBaseUrlInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
console.log('first');
const cookieService = inject(CookieService);
// Prepare default headers but don't overwrite existing ones
const defaultHeaders: Record<string, string> = {
@@ -33,12 +31,13 @@ export class ApiBaseUrlInterceptor implements HttpInterceptor {
}
});
console.log('req.url');
console.log(req.url);
// Only prepend base URL if the request URL is relative (does not start with http or https)
if (!/^https?:\/\//i.test(req.url)) {
const apiReq = req.clone({ url: environment.apiBaseUrl + req.url, setHeaders: headersToSet });
const apiReq = req.clone({
url: environment.apiBaseUrl + req.url,
setHeaders: headersToSet,
credentials: 'include',
});
return next.handle(apiReq);
}
@@ -11,6 +11,8 @@ import { AuthService } from '../services/auth.service';
* - Prevents duplicate refresh token requests
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
console.log('authInterceptor');
const authService = inject(AuthService);
// Skip auth for certain endpoints
@@ -0,0 +1,12 @@
import { ValidatorFn } from '@angular/forms';
export function nationalIdValidator(): ValidatorFn {
return (control) => {
if (control.value === null || control.value === undefined || control.value === '') {
return null;
}
return control.value.length === 10 && /^[0-9]{10}$/.test(control.value)
? null
: { nationalId: true };
};
}
@@ -1,5 +1,23 @@
@if (loading()) {
<shared-page-loading />
} @else {
<router-outlet></router-outlet>
<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">{{ posInfo()?.name }} - فروشگاه {{ posInfo()?.complex?.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"> {{ userInfo()?.username }} </span>
</div>
</div>
</div>
<router-outlet></router-outlet>
</div>
}
@@ -1,20 +1,29 @@
import { AuthService } from '@/core';
import { PageLoadingComponent } from '@/shared/components/page-loading.component';
import { JalaliDateDirective } from '@/shared/directives';
import { Component, computed, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import images from 'src/assets/images';
import { PosStore } from '../pos.store';
@Component({
selector: 'pos-layout',
templateUrl: './layout.component.html',
imports: [PageLoadingComponent, RouterOutlet],
imports: [PageLoadingComponent, RouterOutlet, JalaliDateDirective],
})
export class PosLayoutComponent {
constructor() {}
private readonly store = inject(PosStore);
private readonly authService = inject(AuthService);
readonly loading = computed(() => this.store.loading());
readonly posInfo = computed(() => this.store.entity());
readonly userInfo = computed(() => this.authService.currentAccount());
placeholderLogo = images.placeholders.logo;
now = new Date();
getData() {
this.store.getData();
+22
View File
@@ -0,0 +1,22 @@
import ISummary from '@/core/models/summary';
import { UnitType } from '@/utils';
export interface IGoodRawResponse {
id: string;
name: string;
sku: string;
category: ISummary;
unit_type: UnitType;
pricing_model: string;
description?: string;
created_at: string;
updated_at: string;
}
export interface IGoodResponse extends IGoodRawResponse {}
export interface IGoodCategoryRawResponse {
id: string;
name: string;
goods_count: number;
}
export interface IGoodCategoryResponse extends IGoodCategoryRawResponse {}
@@ -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() === 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() === category.id ? ' bg-amber-400! text-white' : '')
"
>
<span>
{{ category.goods_count }}
</span>
</div>
</div>
}
}
</div>
@@ -0,0 +1,23 @@
import { Component, EventEmitter, inject, Output } from '@angular/core';
import { Skeleton } from 'primeng/skeleton';
import { PosLandingStore } from '../store/main.store';
@Component({
selector: 'pos-good-categories',
templateUrl: './categories.component.html',
imports: [Skeleton],
})
export class PosGoodCategoriesComponent {
@Output() categoryChange = new EventEmitter<string>();
private readonly store = inject(PosLandingStore);
categories = this.store.goodCategories;
loading = this.store.getGoodCategoriesLoading;
selectedCategory = this.store.activeGoodCategory;
changeSelectedCategory(category: { id: string; name: string }) {
this.store.changeActiveCategory(category.id);
}
}
@@ -0,0 +1,16 @@
<p-dialog [(visible)]="visible" header="اطلاعات مشتری" [modal]="true" [style]="{ width: '560px' }">
<div class="card p-2! flex justify-center">
<p-selectbutton [options]="customerTypes" [(ngModel)]="selectedCustomerType" optionValue="value">
<ng-template #item let-item>
<span class="text-sm">{{ item.label }}</span>
</ng-template>
</p-selectbutton>
</div>
@if (selectedCustomerType() === "UNKNOWN") {
<pos-customer-unknown-form (onSubmit)="onSubmitCustomer()" (onClose)="close()" />
} @else if (selectedCustomerType() === "INDIVIDUAL") {
<pos-customer-individual-form (onSubmit)="onSubmitCustomer()" (onClose)="close()" />
} @else if (selectedCustomerType() === "LEGAL") {
<pos-customer-legal-form (onSubmit)="onSubmitCustomer()" (onClose)="close()" />
}
</p-dialog>
@@ -0,0 +1,53 @@
import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { Component, EventEmitter, inject, Output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Dialog } from 'primeng/dialog';
import { SelectButton } from 'primeng/selectbutton';
import { PosLandingStore } from '../../store/main.store';
import { CustomerIndividualFormComponent } from './individual/form.component';
import { CustomerLegalFormComponent } from './legal/form.component';
import { CustomerUnknownFormComponent } from './unknown/form.component';
@Component({
selector: 'pos-order-customer-dialog',
templateUrl: './customer-dialog.component.html',
imports: [
Dialog,
CustomerIndividualFormComponent,
FormsModule,
CustomerLegalFormComponent,
SelectButton,
CustomerUnknownFormComponent,
],
})
export class PosOrderCustomerDialogComponent extends AbstractDialog {
@Output() onSubmit = new EventEmitter<any>();
private readonly store = inject(PosLandingStore);
customerTypes = [
{
label: 'بدون اطلاعات خریدار (نوع دوم)',
value: 'UNKNOWN',
},
{
label: 'خریدار حقیقی (نوع اول)',
value: 'INDIVIDUAL',
},
{
label: 'خریدار حقوقی (نوع اول)',
value: 'LEGAL',
},
] as {
label: string;
value: CustomerType;
}[];
selectedCustomerType = signal<CustomerType>(this.store.customer().type);
onSubmitCustomer() {
this.close();
this.onSubmit.emit();
}
}
@@ -0,0 +1,14 @@
<form [formGroup]="form" (submit)="submit()">
<app-input [control]="form.controls.first_name" name="first_name" label="نام" />
<app-input [control]="form.controls.last_name" name="last_name" label="نام خانوادگی" />
<app-input [control]="form.controls.national_id" name="national_id" label="کد ملی" type="nationalId" />
<app-input
[control]="form.controls.economic_code"
name="economic_code"
label="کد اقتصادی"
type="number"
maxlength="11"
/>
<app-form-footer-actions submitLabel="ثبت مشتری و ادامه" (onCancel)="close()" (onSubmit)="submit()" />
</form>
@@ -0,0 +1,61 @@
import { postalCodeValidator } from '@/core/validators';
import { nationalIdValidator } from '@/core/validators/national-id.validator';
import { AbstractForm } from '@/shared/abstractClasses';
import { InputComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { IIndividualCustomer } from '../../../models/customer';
import { PosLandingStore } from '../../../store/main.store';
@Component({
selector: 'pos-customer-individual-form',
templateUrl: './form.component.html',
imports: [ReactiveFormsModule, InputComponent, FormFooterActionsComponent],
})
export class CustomerIndividualFormComponent extends AbstractForm<
IIndividualCustomer,
IIndividualCustomer
> {
private readonly store = inject(PosLandingStore);
override showSuccessMessage = false;
form = this.fb.group({
first_name: [
this.store.customer().info?.customerIndividuals?.first_name || '',
[Validators.required],
],
last_name: [
this.store.customer().info?.customerIndividuals?.last_name || '',
[Validators.required],
],
national_id: [
this.store.customer().info?.customerIndividuals?.national_id || '',
[Validators.required, nationalIdValidator()],
],
economic_code: [
this.store.customer().info?.customerIndividuals?.economic_code || '',
[Validators.required],
],
postal_code: [
this.store.customer().info?.customerIndividuals?.postal_code || '',
[postalCodeValidator()],
],
});
override submitForm() {
return new Observable<IIndividualCustomer>((observer) => {
const info = this.form.value as IIndividualCustomer;
this.store.setCustomer({
customer_id: '',
type: CustomerType.INDIVIDUAL,
info: {
customerIndividuals: info,
},
});
return observer.next(info);
});
}
}
@@ -0,0 +1,14 @@
<form [formGroup]="form" (submit)="submit()">
<app-input [control]="form.controls.company_name" name="company_name" label="نام مجموعه" />
<app-input [control]="form.controls.registration_number" name="registration_number" label="شماره ثبت" />
<app-input
[control]="form.controls.economic_code"
name="economic_code"
label="کد اقتصادی"
type="number"
maxlength="11"
/>
<app-input [control]="form.controls.postal_code" name="postal_code" label="کد پستی" type="postalCode" />
<app-form-footer-actions submitLabel="ثبت مشتری و ادامه" (onCancel)="close()" (onSubmit)="submit()" />
</form>
@@ -0,0 +1,54 @@
import { postalCodeValidator } from '@/core/validators';
import { AbstractForm } from '@/shared/abstractClasses';
import { InputComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { ILegalCustomer } from '../../../models/customer';
import { PosLandingStore } from '../../../store/main.store';
@Component({
selector: 'pos-customer-legal-form',
templateUrl: './form.component.html',
imports: [ReactiveFormsModule, InputComponent, FormFooterActionsComponent],
})
export class CustomerLegalFormComponent extends AbstractForm<ILegalCustomer, ILegalCustomer> {
private readonly store = inject(PosLandingStore);
override showSuccessMessage = false;
form = this.fb.group({
company_name: [
this.store.customer().info?.customerLegals?.company_name || '',
[Validators.required],
],
registration_number: [
this.store.customer().info?.customerLegals?.registration_number || '',
[Validators.required],
],
economic_code: [
this.store.customer().info?.customerLegals?.economic_code || '',
[Validators.required],
],
postal_code: [
this.store.customer().info?.customerLegals?.postal_code || '',
[postalCodeValidator()],
],
});
override submitForm() {
return new Observable<ILegalCustomer>((observer) => {
const info = this.form.value as ILegalCustomer;
this.store.setCustomer({
customer_id: '',
type: CustomerType.LEGAL,
info: {
customerLegals: info,
},
});
return observer.next(info);
});
}
}
@@ -0,0 +1,5 @@
<form [formGroup]="form" (submit)="submit()">
<app-input [control]="form.controls.national_id" name="national_id" label="شماره ملی" type="nationalId" />
<app-input [control]="form.controls.name" name="name" label="نام" />
<app-form-footer-actions submitLabel="ثبت بدون اطلاعات خریدار و ادامه" (onCancel)="close()" />
</form>
@@ -0,0 +1,42 @@
import { nationalIdValidator } from '@/core/validators/national-id.validator';
import { AbstractForm } from '@/shared/abstractClasses';
import { InputComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { IUnknownCustomer } from '../../../models/customer';
import { PosLandingStore } from '../../../store/main.store';
@Component({
selector: 'pos-customer-unknown-form',
templateUrl: './form.component.html',
imports: [ReactiveFormsModule, InputComponent, FormFooterActionsComponent],
})
export class CustomerUnknownFormComponent extends AbstractForm<IUnknownCustomer, IUnknownCustomer> {
private readonly store = inject(PosLandingStore);
override showSuccessMessage = false;
form = this.fb.group({
national_id: [
this.store?.customer().info?.customerUnknown?.national_id || '',
[nationalIdValidator()],
],
name: [this.store?.customer().info?.customerUnknown?.name || ''],
});
override submitForm() {
return new Observable<IUnknownCustomer>((observer) => {
const info = this.form.value as IUnknownCustomer;
this.store.setCustomer({
customer_id: '',
type: CustomerType.UNKNOWN,
info: {
customerUnknown: info,
},
});
return observer.next(info);
});
}
}
@@ -0,0 +1,35 @@
<div class="flex flex-col">
<div class="sticky top-0 bg-surface-ground z-10 pb-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>
<div class="flex items-center gap-2">
<app-search-input (search)="onSearch($event)"></app-search-input>
<div class="card p-2! flex justify-center">
<p-selectbutton [options]="viewOptions" [(ngModel)]="viewType" optionValue="value">
<ng-template #item let-item>
<i [class]="item.icon"></i>
</ng-template>
</p-selectbutton>
</div>
</div>
</div>
<pos-good-categories (categoryChange)="onCategoryChange($event)" />
</div>
@if (viewType === "grid") {
<pos-good-grid-view (onAdd)="addGood($event)" />
} @else {
<pos-goods-list-view (onAdd)="addGood($event)" />
}
@if (selectedGoodToAdd()) {
<pos-payload-form-dialog
[(visible)]="showPayloadForm"
[good]="selectedGoodToAdd()!"
(onClose)="onClosePayloadForm()"
/>
}
</div>
@@ -0,0 +1,71 @@
import { Maybe } from '@/core';
import { IGoodResponse } from '@/domains/pos/models/good.io';
import { SearchInputComponent } from '@/shared/components/search/search-input.component';
import { Component, computed, inject, Input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SelectButton } from 'primeng/selectbutton';
import { PosLandingStore, TViewType } from '../store/main.store';
import { PosGoodCategoriesComponent } from './categories.component';
import { PosGoodsGridViewComponent } from './grid-view.component';
import { PosGoodsListViewComponent } from './list-view.component';
import { PayloadFormDialogComponent } from './payloads';
@Component({
selector: 'pos-goods',
templateUrl: './goods.component.html',
imports: [
PosGoodCategoriesComponent,
PosGoodsListViewComponent,
SelectButton,
FormsModule,
PosGoodsGridViewComponent,
SearchInputComponent,
PayloadFormDialogComponent,
],
})
export class PosGoodsComponent {
@Input() activeCategory = signal<Maybe<string>>(null);
private readonly store = inject(PosLandingStore);
constructor() {
this.store.initial();
}
viewOptions = [
{ value: 'list', icon: 'pi pi-list' },
{ value: 'grid', icon: 'pi pi-th-large' },
];
showPayloadForm = signal(false);
selectedGoodToAdd = signal<Maybe<IGoodResponse>>(null);
readonly activatedCategoryProducts = computed(() => this.store.activatedCategoryGoods());
readonly stock = computed(() => this.store.goods());
get viewType() {
return this.store.viewType();
}
set viewType(val: TViewType) {
this.store.updateViewType(val);
}
onSearch(searchTerm: string) {
this.store.updateSearchQuery(searchTerm);
this.store.getGoods();
}
onCategoryChange(categoryId: string) {
this.store.changeActiveCategory(categoryId);
}
addGood(good: IGoodResponse) {
this.showPayloadForm.set(true);
this.selectedGoodToAdd.set(good);
}
onClosePayloadForm() {
this.selectedGoodToAdd.set(null);
}
}
@@ -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 (good of goods(); track good.id) {
<div class="flex-1 bg-surface-card rounded-lg p-1 shadow-xs">
<img [src]="goodPlaceholder" class="w-full aspect-[1.3] object-cover rounded-md" />
<div class="mt-2">
<span class="text-lg font-bold">
{{ good.name }}
<!-- <small class="text-xs text-muted-color">({{ good.quantity }})</small> -->
</span>
<div class="flex items-center justify-between mt-1">
<!-- <span [appPriceMask]="good.salePrice" class="text-base font-bold"></span> -->
<button pButton type="button" icon="pi pi-plus" size="small" (click)="addProduct(good)"></button>
</div>
</div>
</div>
}
}
</div>
@@ -0,0 +1,25 @@
import { IGoodResponse } from '@/domains/pos/models/good.io';
import { Component, computed, EventEmitter, inject, Output } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
import images from 'src/assets/images';
import { PosLandingStore } from '../store/main.store';
@Component({
selector: 'pos-good-grid-view',
templateUrl: './grid-view.component.html',
imports: [ButtonDirective, Skeleton],
})
export class PosGoodsGridViewComponent {
private readonly store = inject(PosLandingStore);
@Output() onAdd = new EventEmitter<IGoodResponse>();
goods = computed(() => this.store.activatedCategoryGoods());
loading = computed(() => this.store.getGoodsLoading());
goodPlaceholder = images.placeholders.default;
addProduct(good: IGoodResponse) {
this.onAdd.emit(good);
}
}
@@ -0,0 +1,30 @@
<div class="flex flex-col gap-3 flex-wrap">
@if (loading()) {
@for (_ of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15]; track $index) {
<div class="w-100 h-12">
<p-skeleton width="100%" height="100%"></p-skeleton>
</div>
}
} @else {
@for (good of goods(); track good.id) {
<div class="flex items-center bg-surface-card rounded-lg p-1 shadow-xs gap-2">
<div class="grow flex items-center gap-2">
<img [src]="goodPlaceholder" class="w-12 h-12 object-cover rounded-md" />
<div class="flex flex-col gap-2">
<span class="text-lg font-bold">
{{ good.name }}
<!-- <small class="text-xs text-muted-color">({{ good.quantity }})</small> -->
</span>
<span class="text-sm text-muted-color">
{{ good.sku }}
</span>
</div>
</div>
<div class="shrink-0 flex items-center justify-between gap-3">
<!-- <span [appPriceMask]="good.salePrice" class="text-base font-bold"></span> -->
<button pButton type="button" icon="pi pi-plus" size="small" (click)="addGood(good)"></button>
</div>
</div>
}
}
</div>
@@ -0,0 +1,25 @@
import { IGoodResponse } from '@/domains/pos/models/good.io';
import { Component, computed, EventEmitter, inject, Output } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
import images from 'src/assets/images';
import { PosLandingStore } from '../store/main.store';
@Component({
selector: 'pos-goods-list-view',
templateUrl: './list-view.component.html',
imports: [ButtonDirective, Skeleton],
})
export class PosGoodsListViewComponent {
private readonly store = inject(PosLandingStore);
@Output() onAdd = new EventEmitter<IGoodResponse>();
goods = computed(() => this.store.activatedCategoryGoods());
loading = computed(() => this.store.getGoodsLoading());
goodPlaceholder = images.placeholders.default;
addGood(good: IGoodResponse) {
this.onAdd.emit(good);
}
}
@@ -0,0 +1,28 @@
<div class="flex items-center gap-2 justify-between">
<div class="flex flex-col">
<span class="font-bold text-lg">سفارش #{{ heldOrder.orderNumber }}</span>
<span class="text-sm text-muted-color">
{{ heldOrder.orderItems.length }} کالا -
<span [appPriceMask]="heldOrder.totalAmount"></span>
</span>
</div>
<div class="flex items-center gap-2">
<button
pButton
size="small"
type="button"
label="بارگذاری"
icon="pi pi-download"
(click)="loadHeldOrder(heldOrder.id)"
></button>
<button
pButton
size="small"
type="button"
label="حذف"
icon="pi pi-trash"
severity="danger"
(click)="cancelHeldOrder(heldOrder.id)"
></button>
</div>
</div>
@@ -0,0 +1,57 @@
import { ToastService } from '@/core/services/toast.service';
import { IPosHeldOrderResponse } from '@/modules/pos/models';
import { ConfirmationDialogService } from '@/shared/components/confirmationDialog/confirmation-dialog.service';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { PosService } from '../../services/main.service';
@Component({
selector: 'app-held-order-item',
templateUrl: './held-order-item.component.html',
imports: [PriceMaskDirective, ButtonDirective],
})
export class HeldOrderItemComponent {
@Input() heldOrder!: IPosHeldOrderResponse;
@Input() posId!: number;
@Output() onCancel = new EventEmitter<number>();
@Output() onLoad = new EventEmitter<number>();
constructor(
private readonly posService: PosService,
private readonly confirmationService: ConfirmationDialogService,
private readonly toastService: ToastService,
) {}
actionLoading = signal<boolean>(false);
cancelHeldOrder(heldOrderId: number) {
this.confirmationService.confirm({
message: `آیا از لغو سفارش شماره‌ی #${this.heldOrder.orderNumber} اطمینان دارید؟`,
header: 'لغو سفارش موقت',
acceptLabel: 'بله',
rejectLabel: 'خیر',
accept: () => {
this.actionLoading.set(true);
// this.posService.cancelOrder(this.posId, heldOrderId).subscribe({
// next: () => {
// this.onCancel.emit(heldOrderId);
// this.actionLoading.set(false);
// this.toastService.success({
// text: `سفارش شماره‌ی #${this.heldOrder.orderNumber} با موفقیت لغو شد.`,
// });
// },
// error: () => {
// this.actionLoading.set(false);
// },
// });
},
reject: () => {},
});
}
loadHeldOrder(heldOrderId: number) {
this.onLoad.emit(heldOrderId);
}
}
@@ -0,0 +1,19 @@
<div class="d-flex flex-col gap-4">
<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>
</div>
@if (!heldOrders()?.length) {
<div class="grow flex flex-col gap-5 justify-center items-center text-center text-muted-color">
<span class="text-base text-muted-color pt-4">هیچ سفارش نگه‌داشته شده‌ای وجود ندارد.</span>
</div>
} @else {
<div class="flex flex-col gap-2 mt-3">
@for (heldOrder of heldOrders(); track heldOrder.id) {
<app-held-order-item [heldOrder]="heldOrder" (onCancel)="removeHeldOrder($event)" />
}
</div>
}
</div>
@@ -0,0 +1,39 @@
import { Maybe } from '@/core';
import { IPosHeldOrderResponse } from '@/modules/pos/models';
import { Component, inject, signal } from '@angular/core';
import { PosService } from '../../services/main.service';
import { HeldOrderItemComponent } from './held-order-item.component';
@Component({
selector: 'app-held-orders',
templateUrl: './held-orders.component.html',
imports: [HeldOrderItemComponent],
})
export class HeldOrdersComponent {
private readonly service = inject(PosService);
heldOrders = signal<Maybe<IPosHeldOrderResponse[]>>(null);
loading = signal<boolean>(false);
ngOnInit() {
this.getHeldOrders();
}
getHeldOrders() {
this.loading.set(true);
// this.service.getHeldOrders().subscribe({
// next: (res) => {
// this.heldOrders.set(res.data);
// this.loading.set(false);
// },
// error: () => {
// this.loading.set(false);
// },
// });
}
removeHeldOrder(orderId: number) {
const currentOrders = this.heldOrders() || [];
this.heldOrders.set(currentOrders.filter((order) => order.id !== orderId));
}
}
@@ -0,0 +1,50 @@
<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">{{ inOrderGood.good.name }}</span>
<span class="text-sm text-muted-color">{{ inOrderGood.good.sku }}</span>
</div>
<div class="flex shrink-0 gap-1">
<button
pButton
type="button"
icon="pi pi-pencil"
size="small"
severity="secondary"
class="ms-auto"
(click)="editOrderItem()"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
size="small"
severity="danger"
class="ms-auto"
(click)="removeGoodFromOrder()"
></button>
</div>
</div>
<div class="flex items-center gap-4 mt-auto">
<div class="ms-auto shrink-0">
<!-- <app-uikit-counter
[min]="1"
[max]="inOrderGood.maxQuantity"
[value]="inOrderGood.count"
(valueChange)="updateInOrderGoodQuantity(inOrderGood.goodId, $event)"
></app-uikit-counter> -->
</div>
</div>
</div>
</div>
</div>
<pos-payload-form-dialog
[(visible)]="visible"
[good]="inOrderGood.good"
[initialValues]="inOrderGood"
[orderItemId]="inOrderGood.id"
/>
@@ -0,0 +1,29 @@
import { Component, inject, Input, signal } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import images from 'src/assets/images';
import { IPosInOrderGood } from '../../models';
import { PosLandingStore } from '../../store/main.store';
import { PayloadFormDialogComponent } from '../payload-form.component';
@Component({
selector: 'pos-order-card-template',
templateUrl: './order-card-template.component.html',
imports: [ButtonDirective, PayloadFormDialogComponent],
})
export class OrderCardTemplateComponent {
@Input({ required: true }) inOrderGood!: IPosInOrderGood;
private readonly store = inject(PosLandingStore);
visible = signal(false);
placeholderImage = images.placeholders.default;
removeGoodFromOrder() {
this.store.removeFromInOrderGoods(this.inOrderGood.id);
}
editOrderItem() {
this.visible.set(true);
}
}
@@ -0,0 +1,53 @@
<div class="bg-surface-card w-md shrink-0 h-full overflow-hidden rounded-xl p-4 pb-0 flex flex-col">
<div class="grow overflow-auto flex flex-col">
<div class="flex gap-2 items-center justify-between shrink-0">
<app-key-value label="مشتری" [value]="customerNameToShow()" />
<button pButton outlined icon="pi pi-pencil" size="small" (click)="openCustomerDialog()"></button>
</div>
<hr />
<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 (!inOrderGoods().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 (inOrderGood of inOrderGoods(); track inOrderGood.id) {
@if (inOrderGood.good.pricing_model === "GOLD") {
<gold-payload-order-card [orderItem]="inOrderGood" />
} @else {
<standard-payload-order-card [orderItem]="inOrderGood" />
}
}
</div>
}
</div>
<div class="flex flex-col sticky bottom-0 py-2">
<pos-order-price-info-card />
<div class="sticky bottom-0 pt-4">
<button
pButton
type="button"
label="ثبت و پرداخت"
severity="primary"
icon="pi pi-check"
class="w-full"
[attr.disabled]="inOrderGoods().length === 0 ? true : null"
(click)="submitAndPay()"
></button>
</div>
</div>
</div>
<pos-order-customer-dialog [(visible)]="isVisibleCustomerForm" (onSubmit)="submitCustomer()" />
<!-- <pos-pre-invoice-dialog [(visible)]="isVisiblePreInvoiceForm" /> -->
<pos-payment-form-dialog [(visible)]="isVisiblePaymentForm" (onSubmit)="submitPayment()" />
@@ -0,0 +1,82 @@
// import { CustomersSelectComponent } from '@/modules/customers/components/select/select.component';
// import { ICustomerResponse } from '@/modules/customers/models';
import { KeyValueComponent } from '@/shared/components';
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import images from 'src/assets/images';
import { PosLandingStore } from '../../store/main.store';
import { PosOrderCustomerDialogComponent } from '../customers/customer-dialog.component';
import { GoldPayloadOrderCardComponent } from '../payloads/gold/order-card.component';
import { StandardPayloadOrderCardComponent } from '../payloads/standard/order-card.component';
import { PosPaymentFormDialogComponent } from '../payment/form-dialog.component';
import { POSOrderPriceInfoCardComponent } from './price-info-card.component';
@Component({
selector: 'pos-order-section',
templateUrl: './order-section.component.html',
imports: [
ButtonDirective,
POSOrderPriceInfoCardComponent,
FormsModule,
GoldPayloadOrderCardComponent,
StandardPayloadOrderCardComponent,
PosOrderCustomerDialogComponent,
KeyValueComponent,
PosPaymentFormDialogComponent,
],
})
export class PosOrderSectionComponent {
private readonly store = inject(PosLandingStore);
placeholderImage = images.placeholders.default;
isVisibleCustomerForm = signal(false);
isVisiblePaymentForm = signal(false);
isVisiblePreInvoiceForm = signal(false);
inOrderGoods = computed(() => this.store.inOrderGoods());
customer = computed(() => this.store.customer());
customerNameToShow = computed(() => {
const type = this.store.customer().type;
const info = this.store.customer().info;
let customerNameToShow = 'نامشخص';
switch (type) {
case 'UNKNOWN':
customerNameToShow = `${info?.customerUnknown?.name || 'نامشخص'} (نوع دوم)`;
break;
case 'INDIVIDUAL':
customerNameToShow = `${info?.customerIndividuals ? info?.customerIndividuals.first_name + ' ' + info?.customerIndividuals.last_name : 'نامشخص'} (نوع اول)`;
break;
case 'LEGAL':
customerNameToShow = `${info?.customerLegals?.company_name || 'نامشخص'} (نوع اول)`;
break;
}
return customerNameToShow;
});
clearOrderList() {
this.store.resetInOrderGoods();
}
submit() {
this.store.submitOrder();
}
openCustomerDialog() {
// this.this.store.submitOrder();
this.isVisibleCustomerForm.set(true);
}
submitAndPay() {
// this.this.store.submitOrder();
this.isVisiblePaymentForm.set(true);
}
submitCustomer() {
console.log('submitCustomer');
}
submitPayment() {}
}
@@ -0,0 +1,30 @@
<p-card class="border border-surface-border">
<div class="flex flex-col gap-3">
<div class="flex justify-between items-center">
<span>جمع قیمت</span>
<div class="flex flex-col items-end">
<!-- @if (priceInfo().discountAmount) {
<span class="line-through text-muted-color text-xs" [appPriceMask]="priceInfo().totalBaseAmount"></span>
}
<span [appPriceMask]="priceInfo().totalBaseAmount - priceInfo().discountAmount"></span>-->
<span [appPriceMask]="priceInfo().totalBaseAmount"></span>
<!-- </div>
} @else {
<span [appPriceMask]="priceInfo().totalBaseAmount"></span> -->
</div>
</div>
<div class="flex justify-between items-center">
<span>تخفیف</span>
<span [appPriceMask]="priceInfo().discountAmount"></span>
</div>
<div class="flex justify-between items-center">
<span>مالیات</span>
<span [appPriceMask]="priceInfo().taxAmount"></span>
</div>
<hr />
<div class="flex justify-between items-center font-bold text-lg">
<span>مجموع کل</span>
<span [appPriceMask]="priceInfo().totalAmount"></span>
</div>
</div>
</p-card>
@@ -0,0 +1,15 @@
import { PriceMaskDirective } from '@/shared/directives';
import { Component, computed, inject } from '@angular/core';
import { Card } from 'primeng/card';
import { PosLandingStore } from '../../store/main.store';
@Component({
selector: 'pos-order-price-info-card',
templateUrl: './price-info-card.component.html',
imports: [Card, PriceMaskDirective],
})
export class POSOrderPriceInfoCardComponent {
private readonly store = inject(PosLandingStore);
priceInfo = computed(() => this.store.orderPricingInfo());
}
@@ -0,0 +1,20 @@
<p-dialog
[(visible)]="visible"
[header]="preparedTitle()"
[modal]="true"
[style]="{ width: '500px' }"
[closable]="true"
(onHide)="close()"
>
@if (good) {
@if (isGoldMode()) {
<pos-gold-payload-form [initialValues]="goldPayload()" (onSubmit)="submit($event)" />
} @else if (isStandardMode()) {
<pos-standard-payload-form
[initialValues]="standardPayload()"
[unitType]="good.unit_type"
(onSubmit)="submit($event)"
/>
}
}
</p-dialog>
@@ -0,0 +1,53 @@
import { IGoodResponse } from '@/domains/pos/models/good.io';
import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog';
import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { Dialog } from 'primeng/dialog';
import { IGoldPayload, IPosOrderItem, IStandardPayload, TPosOrderGoodPayload } from '../models';
import { PosLandingStore } from '../store/main.store';
import { PosGoldPayloadFormComponent } from './payloads/gold/form.component';
import { PosStandardPayloadFormComponent } from './payloads/standard/form.component';
@Component({
selector: 'pos-payload-form-dialog',
templateUrl: './payload-form.component.html',
imports: [PosGoldPayloadFormComponent, Dialog, PosStandardPayloadFormComponent],
})
export class PayloadFormDialogComponent extends AbstractDialog {
@Input({ required: true }) good!: IGoodResponse;
@Input() initialValues?: IPosOrderItem;
@Input() orderItemId?: string;
@Output() onSubmit = new EventEmitter<TPosOrderGoodPayload>();
private readonly store = inject(PosLandingStore);
totalAmount = signal<number>(0);
preparedTitle = computed(() => this.good?.name);
editMode = computed(() => Boolean(this.initialValues) && Boolean(this.orderItemId));
isGoldMode = computed(() => this.good.pricing_model === 'GOLD');
isStandardMode = computed(() => this.good.pricing_model === 'STANDARD');
goldPayload = computed(() =>
this.isGoldMode() && this.editMode()
? (this.initialValues as IPosOrderItem<IGoldPayload>)
: undefined,
);
standardPayload = computed(() =>
this.isStandardMode() && this.editMode()
? (this.initialValues as IPosOrderItem<IStandardPayload>)
: undefined,
);
onChangeTotalAmount = (totalAmount: number) => {
this.totalAmount.set(totalAmount);
};
submit(payload: IPosOrderItem) {
if (this.editMode()) {
this.store.editInOrderGoods({ id: this.orderItemId!, good: this.good, ...payload });
} else {
this.store.addToInOrderGoods(this.good.id, payload);
}
this.close();
}
}
@@ -0,0 +1,24 @@
<p-card class="border-none w-full">
<div class="flex flex-col gap-3">
<div class="flex justify-between items-center">
<span>جمع قیمت</span>
@if (!discountAmount) {
<span [appPriceMask]="baseTotalAmount"></span>
} @else {
<div class="flex flex-col items-end">
<span class="line-through text-muted-color text-xs" [appPriceMask]="baseTotalAmount"></span>
<span [appPriceMask]="baseTotalAmount - discountAmount"></span>
</div>
}
</div>
<div class="flex justify-between items-center">
<span>مالیات (۱۰٪)</span>
<span [appPriceMask]="taxAmount"></span>
</div>
<hr />
<div class="flex justify-between items-center font-bold text-lg">
<span>مجموع کل</span>
<span [appPriceMask]="totalAmount"></span>
</div>
</div>
</p-card>
@@ -0,0 +1,15 @@
import { PriceMaskDirective } from '@/shared/directives';
import { Component, Input } from '@angular/core';
import { Card } from 'primeng/card';
@Component({
selector: 'pos-form-dialog-amount-card-template',
templateUrl: './form-dialog-amount-card-template.component.html',
imports: [Card, PriceMaskDirective],
})
export class PosFormDialogAmountCardTemplateComponent {
@Input({ required: true }) baseTotalAmount!: number;
@Input({ required: true }) totalAmount!: number;
@Input({ required: true }) discountAmount!: number;
@Input({ required: true }) taxAmount!: number;
}
@@ -0,0 +1,52 @@
<form [formGroup]="form">
<app-input [control]="form.controls.unit_price" name="unit_price" label="قیمت پایه هر گرم" type="price" />
<app-input [control]="form.controls.quantity" name="quantity" label="مقدار" type="number" suffix="گرم" />
<app-enum-select [control]="form.controls.payload.controls.karat" type="goldKarat" />
<app-input
[control]="form.controls.payload.controls.wages"
name="wages"
[label]="`اجرت ${toPriceFormat(wagePrice())}`"
type="number"
suffix="درصد"
[min]="0"
[max]="100"
/>
<app-input
[control]="form.controls.payload.controls.commission"
name="commission"
[label]="`حق‌العمل ${toPriceFormat(commissionPrice())}`"
type="number"
suffix="درصد"
[min]="0"
[max]="100"
/>
<app-input
[control]="form.controls.payload.controls.profit"
name="profit"
[label]="`سود ${toPriceFormat(profitPrice())}`"
type="number"
suffix="درصد"
[min]="0"
[max]="100"
/>
<app-input
[control]="form.controls.discount"
name="discount"
label="تخفیف"
type="number"
suffix="درصد"
[min]="0"
[max]="100"
/>
<hr />
<div class="flex flex-col gap-4 justify-center w-full">
<pos-form-dialog-amount-card-template
[totalAmount]="totalAmount()"
[baseTotalAmount]="baseTotalAmount()"
[discountAmount]="discountAmount()"
[taxAmount]="taxAmount()"
/>
<p-button (onClick)="submit()"> افزودن به لیست خرید </p-button>
</div>
</form>
@@ -0,0 +1,108 @@
import { AbstractForm } from '@/shared/abstractClasses';
import { EnumSelectComponent } from '@/shared/apiEnum/select.component';
import { InputComponent } from '@/shared/components';
import { TGoldKarat } from '@/shared/localEnum/constants/goldKarat';
import { formatWithCurrency } from '@/utils';
import { Component, EventEmitter, Output, signal } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { Button } from 'primeng/button';
import { IGoldPayload, IPosOrderItem } from '../../../models';
import { PosFormDialogAmountCardTemplateComponent } from '../form-dialog-amount-card-template.component';
@Component({
selector: 'pos-gold-payload-form',
templateUrl: './form.component.html',
imports: [
ReactiveFormsModule,
InputComponent,
EnumSelectComponent,
Button,
PosFormDialogAmountCardTemplateComponent,
],
})
export class PosGoldPayloadFormComponent extends AbstractForm<
IPosOrderItem<IGoldPayload>,
IPosOrderItem<IGoldPayload>
> {
@Output() onSubmitForm = new EventEmitter<IGoldPayload>();
@Output() onChangeTotalAmount = new EventEmitter<number>();
private readonly initialForm = () => {
const form = this.fb.group({
unit_price: [this.initialValues?.unit_price || 200_000_000, [Validators.required]],
quantity: [this.initialValues?.quantity || 2, [Validators.required]],
discount: [this.initialValues?.discount || 0],
payload: this.fb.group({
commission: [this.initialValues?.payload?.commission || 10, [Validators.required]],
karat: [this.initialValues?.payload?.karat || '', [Validators.required]],
wages: [this.initialValues?.payload?.wages || 10, [Validators.required]],
profit: [this.initialValues?.payload?.profit || 10, [Validators.required]],
}),
});
form.valueChanges.subscribe((value) => {
this.updateCalculateAmount(value as IPosOrderItem<IGoldPayload>);
});
return form;
};
form = this.initialForm();
override submitForm(payload: IPosOrderItem) {
this.onSubmit.emit({
...payload,
total_amount: this.totalAmount(),
base_total_amount: this.baseTotalAmount(),
discount_amount: this.discountAmount(),
tax_amount: this.taxAmount(),
payload: {
commission_amount: this.commissionPrice(),
profit_amount: this.profitPrice(),
wages_amount: this.wagePrice(),
commission: this.form.controls.payload.controls.commission.value || 0,
karat: this.form.controls.payload.controls.karat.value as TGoldKarat,
profit: this.form.controls.payload.controls.profit.value || 0,
wages: this.form.controls.payload.controls.wages.value || 0,
},
});
}
wagePrice = signal<number>(0);
karatPrice = signal<number>(0);
commissionPrice = signal<number>(0);
profitPrice = signal<number>(0);
totalAmount = signal<number>(0);
baseTotalAmount = signal<number>(0);
discountAmount = signal<number>(0);
taxAmount = signal<number>(0);
toPriceFormat(amount: number) {
if (!amount) {
return '';
}
return `(${formatWithCurrency(amount)})`;
}
updateCalculateAmount(value: Partial<IPosOrderItem<IGoldPayload>>) {
const unitWithQuantity = (value.unit_price ?? 0) * (value.quantity ?? 0);
const commissionPrice = (unitWithQuantity * Number(value.payload?.commission ?? '0')) / 100;
const wagePrice = (unitWithQuantity * Number(value.payload?.wages ?? '0')) / 100;
const totalPriceBeforeProfit = unitWithQuantity + wagePrice + commissionPrice;
const profitPrice = (totalPriceBeforeProfit * Number(value.payload?.profit ?? '0')) / 100;
const baseTotalAmount = unitWithQuantity + profitPrice + commissionPrice + wagePrice;
const discountPrice = (baseTotalAmount * Number(value.discount || '0')) / 100;
const taxAmount = (profitPrice + commissionPrice + wagePrice - discountPrice) * 0.1;
this.wagePrice.set(wagePrice);
this.commissionPrice.set(commissionPrice);
this.profitPrice.set(profitPrice);
this.discountAmount.set(discountPrice);
this.taxAmount.set(taxAmount);
this.baseTotalAmount.set(baseTotalAmount);
this.totalAmount.set(baseTotalAmount - discountPrice + taxAmount);
}
}
@@ -0,0 +1 @@
<pos-order-card-template [inOrderGood]="orderItem"></pos-order-card-template>
@@ -0,0 +1,13 @@
import { Component, Input } from '@angular/core';
import { IPosInOrderGood } from '../../../models';
import { OrderCardTemplateComponent } from '../../order/order-card-template.component';
@Component({
selector: 'gold-payload-order-card',
templateUrl: './order-card.component.html',
imports: [OrderCardTemplateComponent],
})
export class GoldPayloadOrderCardComponent {
@Input({ required: true }) orderItem!: IPosInOrderGood;
constructor() {}
}
@@ -0,0 +1 @@
export * from '../payload-form.component';
@@ -0,0 +1,31 @@
<form [formGroup]="form" (submit)="submit()">
<app-input [control]="form.controls.unit_price" name="unit_price" label="قیمت پایه" type="price" />
<app-input
[control]="form.controls.quantity"
name="quantity"
[label]="preparedFormVisuals().quantityLabel"
type="number"
[suffix]="preparedFormVisuals().quantitySymbolText"
[min]="0"
/>
<app-input
[control]="form.controls.discount"
name="discount"
label="تخفیف"
type="number"
suffix="درصد"
[min]="0"
[max]="100"
/>
<hr />
<div class="flex flex-col gap-4 justify-center w-full">
<pos-form-dialog-amount-card-template
[totalAmount]="totalAmount()"
[baseTotalAmount]="baseTotalAmount()"
[discountAmount]="discountAmount()"
[taxAmount]="taxAmount()"
/>
<p-button (onClick)="submit()"> افزودن به لیست خرید </p-button>
</div>
</form>
@@ -0,0 +1,72 @@
import { AbstractForm } from '@/shared/abstractClasses';
import { InputComponent } from '@/shared/components';
import { formatWithCurrency, getGoodUnitTypeProperties, UnitType } from '@/utils';
import { Component, computed, EventEmitter, Input, Output, signal } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { Button } from 'primeng/button';
import { IPosOrderItem, IStandardPayload } from '../../../models';
import { PosFormDialogAmountCardTemplateComponent } from '../form-dialog-amount-card-template.component';
@Component({
selector: 'pos-standard-payload-form',
templateUrl: './form.component.html',
imports: [ReactiveFormsModule, InputComponent, Button, PosFormDialogAmountCardTemplateComponent],
})
export class PosStandardPayloadFormComponent extends AbstractForm<
IPosOrderItem<IStandardPayload>,
IPosOrderItem<IStandardPayload>
> {
@Input({ required: true }) unitType!: UnitType;
@Output() onChangeTotalAmount = new EventEmitter<number>();
private readonly initialForm = () => {
const form = this.fb.group({
unit_price: [this.initialValues?.unit_price || 0, [Validators.required]],
quantity: [this.initialValues?.quantity || 0, [Validators.required]],
discount: [this.initialValues?.discount || ''],
});
form.valueChanges.subscribe((value) => {
this.updateCalculateAmount(value as Partial<IPosOrderItem<IStandardPayload>>);
});
return form;
};
form = this.initialForm();
override submitForm(payload: IPosOrderItem<IStandardPayload>) {
this.onSubmit.emit({
...payload,
total_amount: this.totalAmount(),
discount_amount: this.discountAmount(),
tax_amount: this.taxAmount(),
base_total_amount: this.baseTotalAmount(),
});
}
preparedFormVisuals = computed(() => getGoodUnitTypeProperties(this.unitType));
baseTotalAmount = signal<number>(0);
totalAmount = signal<number>(0);
discountAmount = signal<number>(0);
taxAmount = signal<number>(0);
toPriceFormat(amount: number) {
if (!amount) {
return '';
}
return `(${formatWithCurrency(amount)})`;
}
updateCalculateAmount(value: Partial<IPosOrderItem<IStandardPayload>>) {
const baseTotalAmount = (value.unit_price ?? 0) * (value.quantity ?? 0);
const discountAmount = (baseTotalAmount * Number(value.discount || '0')) / 100;
const baseTotalAmountWithoutTax = baseTotalAmount - discountAmount;
const taxAmount = baseTotalAmountWithoutTax * 0.1;
this.baseTotalAmount.set(baseTotalAmount);
this.discountAmount.set(discountAmount);
this.taxAmount.set(taxAmount);
this.totalAmount.set(baseTotalAmountWithoutTax + taxAmount);
}
}
@@ -0,0 +1 @@
<pos-order-card-template [inOrderGood]="orderItem"></pos-order-card-template>
@@ -0,0 +1,13 @@
import { Component, Input } from '@angular/core';
import { IPosInOrderGood } from '../../../models';
import { OrderCardTemplateComponent } from '../../order/order-card-template.component';
@Component({
selector: 'standard-payload-order-card',
templateUrl: './order-card.component.html',
imports: [OrderCardTemplateComponent],
})
export class StandardPayloadOrderCardComponent {
@Input({ required: true }) orderItem!: IPosInOrderGood;
constructor() {}
}
@@ -0,0 +1,49 @@
<p-dialog
[(visible)]="visible"
header="اطلاعات پرداخت"
[modal]="true"
[style]="{ width: '500px' }"
[closable]="true"
(onHide)="close()"
>
<form [formGroup]="form" (submit)="submit()">
<app-key-value label="مبلغ قابل پرداخت" [value]="totalAmount().toString()" type="price" />
<app-key-value label="مبلغ باقی‌مانده" [value]="remainedAmount().toString()" type="price" />
<hr />
<app-input
[control]="form.controls.terminal"
name="terminal"
label="پرداخت با پایانه"
type="price"
[max]="terminalMax()"
>
<ng-template #suffixTemp>
<button pButton size="small" outlined type="button" class="border-s-0!" (click)="fillRemained('TERMINAL')">
تمامی بدهی باقی‌مانده
</button>
</ng-template>
</app-input>
<app-input [control]="form.controls.cash" name="cash" label="نقدی" type="price" [max]="cashMax()">
<ng-template #suffixTemp>
<button pButton size="small" outlined type="button" class="border-s-0!" (click)="fillRemained('CASH')">
تمامی بدهی باقی‌مانده
</button>
</ng-template>
</app-input>
<app-input [control]="form.controls.set_off" name="setOff" label="تهاتر" type="price" [max]="setOffMax()">
<ng-template #suffixTemp>
<button pButton size="small" outlined type="button" class="border-s-0!" (click)="fillRemained('SET_OFF')">
تمامی بدهی باقی‌مانده
</button>
</ng-template>
</app-input>
<app-form-footer-actions
submitLabel="ثبت اطلاعات پرداخت و ادامه"
[loading]="submitOrderLoading()"
(onCancel)="close()"
(onSubmit)="submit()"
/>
</form>
</p-dialog>
@@ -0,0 +1,116 @@
import { ToastService } from '@/core/services/toast.service';
import { AbstractFormDialog } from '@/shared/abstractClasses';
import { InputComponent, KeyValueComponent } from '@/shared/components';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { Component, computed, inject, signal } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { Dialog } from 'primeng/dialog';
import { Observable } from 'rxjs';
import { IPayment, TOrderPaymentTypes } from '../../models';
import { PosLandingStore } from '../../store/main.store';
@Component({
selector: 'pos-payment-form-dialog',
templateUrl: './form-dialog.component.html',
imports: [
ReactiveFormsModule,
InputComponent,
FormFooterActionsComponent,
KeyValueComponent,
ButtonDirective,
Dialog,
],
})
export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment, IPayment> {
private readonly store = inject(PosLandingStore);
private readonly toastServices = inject(ToastService);
readonly totalAmount = computed(() => this.store.orderPricingInfo().totalAmount);
readonly remainedAmount = signal(this.totalAmount());
setOffMax = signal(this.remainedAmount());
cashMax = signal(this.remainedAmount());
terminalMax = signal(this.remainedAmount());
submitOrderLoading = computed(() => this.store.submitOrderLoading());
private initForm = () => {
const form = this.fb.group({
set_off: [this.initialValues?.set_off || 0],
cash: [this.initialValues?.cash || 0],
terminal: [this.initialValues?.terminal || 0],
});
form.valueChanges.subscribe((value) => {
const totalValue = (value.cash || 0) + (value.set_off || 0) + (value.terminal || 0);
const remainedAmount = this.totalAmount() - totalValue;
const setOffMax = remainedAmount + (value.set_off || 0);
const cashMax = remainedAmount + (value.cash || 0);
const terminalMax = remainedAmount + (value.terminal || 0);
form.controls.set_off.clearValidators();
form.controls.set_off.addValidators([Validators.max(setOffMax)]);
form.controls.cash.clearValidators();
form.controls.cash.addValidators([Validators.max(cashMax)]);
form.controls.terminal.clearValidators();
form.controls.terminal.addValidators([Validators.max(terminalMax)]);
this.remainedAmount.set(remainedAmount);
this.cashMax.set(cashMax);
this.terminalMax.set(terminalMax);
this.setOffMax.set(setOffMax);
});
return form;
};
form = this.initForm();
fillRemained(type: TOrderPaymentTypes) {
switch (type) {
case 'TERMINAL':
this.form.controls.terminal.setValue(
(this.form.controls.terminal.value || 0) + this.remainedAmount(),
);
break;
case 'CASH':
this.form.controls.cash.setValue(
(this.form.controls.cash.value || 0) + this.remainedAmount(),
);
break;
case 'SET_OFF':
this.form.controls.set_off.setValue(
(this.form.controls.set_off.value || 0) + this.remainedAmount(),
);
break;
}
}
override submitForm() {
if (this.remainedAmount() > 0) {
return this.toastServices.warn({
text: 'فاکتور تسویه نشده است. لطفا مبلغ پرداخت را کامل کنید..',
});
}
const payment = this.form.value as IPayment;
this.store.setPayment(payment);
this.store.submitOrder().then((res) => {
if (res) {
this.close();
this.toastServices.success({
text: 'فاکتور شما با موفقیت ایجاد شد',
});
}
});
return new Observable<IPayment>((observer) => observer.next(payment));
}
override onSuccess(response: IPayment): void {}
}
@@ -0,0 +1,11 @@
<p-dialog
[(visible)]="visible"
header="پیش فاکتور"
[modal]="true"
[style]="{ width: '500px' }"
[closable]="true"
(onHide)="close()"
>
<form [formGroup]="form"></form>
<uikit-datepicker [control]="form.controls.invoiceDate" label="تاریخ فاکتور" />
</p-dialog>
@@ -0,0 +1,25 @@
import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog';
import { UikitFlatpickrJalaliComponent } from '@/uikit';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Dialog } from 'primeng/dialog';
import { PosLandingStore } from '../store/main.store';
@Component({
selector: 'pos-pre-invoice-dialog',
templateUrl: './pre-invoice-dialog.component.html',
imports: [Dialog, UikitFlatpickrJalaliComponent, ReactiveFormsModule],
})
export class PosPreInvoiceDialogComponent extends AbstractDialog {
private readonly store = inject(PosLandingStore);
private readonly fb = inject(FormBuilder);
private initForm() {
const form = this.fb.group({
invoiceDate: [this.store.invoiceDate(), [Validators.required]],
});
return form;
}
form = this.initForm();
}
@@ -3,4 +3,6 @@ const baseUrl = '/api/v1/pos';
export const POS_API_ROUTES = {
info: () => `${baseUrl}`,
goods: () => `${baseUrl}/goods`,
goodCategories: () => `${baseUrl}/good_categories`,
submitOrder: () => `${baseUrl}/sales_invoices`,
};
@@ -1,13 +0,0 @@
import { TAccountType } from '@/core/constants/accountTypes.const';
export interface IAccountRawResponse {
username: string;
id: string;
}
export interface IAccountResponse extends IAccountRawResponse {}
export interface IAccountRequest {
username: string;
password?: string;
type: TAccountType;
}
@@ -0,0 +1,28 @@
export interface IIndividualCustomer {
first_name: string;
last_name: string;
national_id: string;
postal_code: string;
economic_code: string;
is_favorite?: boolean;
}
export interface ILegalCustomer {
company_name: string;
economic_code: string;
registration_number: string;
postal_code: string;
is_favorite?: boolean;
}
export interface IUnknownCustomer {
national_id: string;
name: string;
}
export interface ICustomer {
customerIndividuals?: IIndividualCustomer;
customerLegals?: ILegalCustomer;
customerUnknown?: IUnknownCustomer;
}
export type TCustomerInfo = IIndividualCustomer | ILegalCustomer | IUnknownCustomer;
@@ -1,2 +1,5 @@
export * from './accounts_io';
export * from './customer';
export * from './io';
export * from './payload';
export * from './payment';
export * from './types';
+15 -15
View File
@@ -1,18 +1,18 @@
import { TRoles } from '@/core';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { ICustomer } from './customer';
import { IPayment } from './payment';
import { IPosOrderItem } from './types';
export interface IUserRawResponse {
mobile_number: string;
first_name: string;
last_name: string;
fullname: string;
id: string;
role: TRoles;
export interface IPosOrderRequest {
total_amount: number;
invoice_date: string;
payments: IPayment;
items: IPosOrderItem[];
notes?: string;
customer_type?: CustomerType;
customerId?: string;
customer?: ICustomer;
}
export interface IUserResponse extends IUserRawResponse {}
export interface IUserRequest {
first_name: string;
last_name: string;
mobile_number: string;
role: TRoles;
}
export interface IPosOrderRawResponse {}
export interface IPosOrderResponse extends IPosOrderRawResponse {}
@@ -0,0 +1,15 @@
import { TGoldKarat } from '@/shared/localEnum/constants/goldKarat';
export interface IGoldPayload {
karat: TGoldKarat;
wages: number;
profit: number;
commission: number;
wages_amount: number;
profit_amount: number;
commission_amount: number;
}
export interface IStandardPayload {}
export type TPosOrderGoodPayload = IGoldPayload | IStandardPayload;
@@ -0,0 +1,7 @@
export interface IPayment {
terminal: number;
cash: number;
set_off: number;
}
export type TOrderPaymentTypes = 'TERMINAL' | 'CASH' | 'SET_OFF';
@@ -0,0 +1,24 @@
import { IGoodResponse } from '@/domains/pos/models/good.io';
import { UnitType } from '@/utils';
import { TPosOrderGoodPayload } from './payload';
export interface IPosOrderItem<T = TPosOrderGoodPayload> {
unit_price: number;
quantity: number;
total_amount: number;
discount_amount: number;
unit_type: UnitType;
good_id?: string;
service_id?: string;
notes?: string;
payload: T;
base_total_amount: number;
discount: number;
tax_amount: number;
}
export interface IPosInOrderGood extends Omit<IPosOrderItem, 'good_id' | 'service_id' | 'notes'> {
id: string;
good: IGoodResponse;
}
@@ -1,9 +1,16 @@
import { IListingResponse } from '@/core/models/service.model';
import {
IGoodCategoryRawResponse,
IGoodCategoryResponse,
IGoodRawResponse,
IGoodResponse,
} from '@/domains/pos/models/good.io';
import { IPosInfoRawResponse, IPosInfoResponse } from '@/domains/pos/models/pos.io';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { POS_API_ROUTES } from '../constants';
import { IUserRawResponse, IUserResponse } from '../models';
import { IPosOrderRawResponse, IPosOrderRequest, IPosOrderResponse } from '../models';
@Injectable({ providedIn: 'root' })
export class PosService {
@@ -14,7 +21,17 @@ export class PosService {
getInfo(): Observable<IPosInfoResponse> {
return this.http.get<IPosInfoRawResponse>(this.apiRoutes.info());
}
getGoods(): Observable<IUserResponse> {
return this.http.get<IUserRawResponse>(this.apiRoutes.goods());
getGoods(searchQuery: string): Observable<IListingResponse<IGoodResponse>> {
return this.http.get<IListingResponse<IGoodRawResponse>>(this.apiRoutes.goods());
}
getGoodCategories(): Observable<IListingResponse<IGoodCategoryResponse>> {
return this.http.get<IListingResponse<IGoodCategoryRawResponse>>(
this.apiRoutes.goodCategories(),
);
}
submitOrder(payload: IPosOrderRequest): Observable<IPosOrderResponse> {
return this.http.post<IPosOrderRawResponse>(this.apiRoutes.submitOrder(), payload);
}
}
@@ -0,0 +1,249 @@
import { Maybe } from '@/core';
// import { ICustomerResponse } from '@/modules/customers/models';
import { IGoodCategoryResponse, IGoodResponse } from '@/domains/pos/models/good.io';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { createUUID, JALALI_DATE_FORMATS, nowJalali, parseJalali } from '@/utils';
import { computed, inject, Injectable, signal } from '@angular/core';
import { catchError, finalize, map, Observable, throwError } from 'rxjs';
import { ICustomer, IPayment, IPosOrderRequest, IPosOrderResponse } from '../models';
import { IPosInOrderGood, IPosOrderItem } from '../models/types';
import { PosService } from '../services/main.service';
export type TViewType = 'list' | 'grid';
interface IPosLandingState {
getGoodsLoading: boolean;
goods: Maybe<IGoodResponse[]>;
getGoodCategoriesLoading: boolean;
goodCategories: Maybe<IGoodCategoryResponse[]>;
activeGoodCategory: Maybe<string>;
inOrderGoods: IPosInOrderGood[];
viewType: TViewType;
searchQuery: string;
customerDetails: {
customer_id?: string;
info?: ICustomer;
type: CustomerType;
};
invoiceDate: string;
orderNote?: string;
payments: IPayment;
submitOrderLoading: boolean;
}
export const INITIAL_POS_STATE: IPosLandingState = {
getGoodsLoading: true,
goods: null,
getGoodCategoriesLoading: true,
goodCategories: null,
activeGoodCategory: null,
inOrderGoods: [],
viewType: 'grid',
searchQuery: '',
customerDetails: {
type: CustomerType.UNKNOWN,
},
invoiceDate: nowJalali(JALALI_DATE_FORMATS.NUMERIC),
payments: {
cash: 0,
set_off: 0,
terminal: 0,
},
submitOrderLoading: false,
};
@Injectable({ providedIn: 'any' })
export class PosLandingStore {
private readonly service = inject(PosService);
private state$ = signal<IPosLandingState>({ ...INITIAL_POS_STATE });
readonly goods = computed(() => this.state$().goods);
readonly getGoodsLoading = computed(() => this.state$().getGoodsLoading);
readonly inOrderGoods = computed(() => this.state$().inOrderGoods);
readonly goodCategories = computed(() => this.state$().goodCategories);
readonly getGoodCategoriesLoading = computed(() => this.state$().getGoodCategoriesLoading);
readonly activeGoodCategory = computed(() => this.state$().activeGoodCategory);
readonly viewType = computed(() => this.state$().viewType);
readonly searchQuery = computed(() => this.state$().searchQuery);
readonly submitOrderLoading = computed(() => this.state$().submitOrderLoading);
readonly activatedCategoryGoods = computed(() => {
if (!this.activeGoodCategory()) {
return this.state$().goods;
}
return this.state$().goods?.filter((s) => s.category.id === this.state$().activeGoodCategory);
});
readonly customer = computed(() => this.state$().customerDetails);
readonly invoiceDate = computed(() => this.state$().invoiceDate);
readonly payments = computed(() => this.state$().payments);
readonly orderPricingInfo = computed(() => {
const totalAmount = 0;
const taxAmount = totalAmount * 0.1;
return {
totalItems: this.inOrderGoods().length,
totalBaseAmount: this.inOrderGoods().reduce((acc, curr) => acc + curr.base_total_amount, 0),
totalAmount: this.inOrderGoods().reduce((acc, curr) => acc + curr.total_amount, 0),
taxAmount: this.inOrderGoods().reduce((acc, curr) => acc + curr.tax_amount, 0),
discountAmount: this.inOrderGoods().reduce((acc, curr) => acc + curr.discount_amount, 0),
payableAmount: totalAmount + taxAmount,
};
});
setState(partial: Partial<IPosLandingState>) {
this.state$.set({ ...this.state$(), ...partial });
}
reset() {
this.state$.set({ ...INITIAL_POS_STATE });
}
initial() {
this.getGoods();
this.getGoodCategories();
}
fillInitial(data: Partial<IPosLandingState>) {
this.state$.set({ ...INITIAL_POS_STATE, ...data });
}
getGoods() {
this.setState({ getGoodsLoading: true });
this.service.getGoods(this.state$().searchQuery).subscribe({
next: (res) => {
this.setState({ goods: res.data, getGoodsLoading: false });
},
error: () => {
this.setState({ getGoodsLoading: false });
},
});
}
getGoodCategories() {
this.setState({ getGoodCategoriesLoading: true });
this.service
.getGoodCategories()
.pipe(
map((res) => {
return [
{
id: '',
name: 'همه',
goods_count: res.data.reduce((acc, curr) => acc + curr.goods_count, 0),
},
...res.data,
];
}),
)
.subscribe({
next: (res) => {
this.setState({
goodCategories: res,
getGoodCategoriesLoading: false,
activeGoodCategory: res[0]?.id,
});
},
error: () => {
this.setState({ getGoodCategoriesLoading: false });
},
});
}
changeActiveCategory(categoryId: string) {
this.setState({ activeGoodCategory: categoryId });
}
editInOrderGoods(inOrderGood: IPosInOrderGood) {
this.setState({
inOrderGoods: [...this.inOrderGoods()].map((orderItem) => {
if (inOrderGood.id === orderItem.id) {
return inOrderGood;
}
return orderItem;
}),
});
}
addToInOrderGoods(goodId: string, payload: IPosOrderItem) {
const good = this.state$().goods?.find((s) => s.id === goodId);
if (!good) return;
this.setState({
inOrderGoods: [
...this.inOrderGoods(),
{
id: createUUID(),
good,
...payload,
},
],
});
}
removeFromInOrderGoods(orderItemId: string) {
this.setState({
inOrderGoods: this.state$().inOrderGoods.filter((p) => p.id !== orderItemId),
});
}
updateViewType(viewType: TViewType) {
this.setState({ viewType });
}
updateSearchQuery(searchQuery: string) {
this.setState({ searchQuery });
}
resetInOrderGoods() {
this.setState({ inOrderGoods: [] });
}
setCustomer(customer: { customer_id?: string; info?: ICustomer; type: CustomerType }) {
console.log(customer);
this.setState({ customerDetails: customer });
}
setPayment(payments: IPayment) {
this.setState({ payments });
}
async submitOrder() {
this.setState({ submitOrderLoading: true });
const orderPayload: IPosOrderRequest = {
customerId: this.customer().customer_id,
customer_type: this.customer().type,
customer: this.customer().info,
items: this.inOrderGoods().map(({ good, ...rest }) => ({
...rest,
good_id: good.id,
unit_type: good.unit_type,
})),
invoice_date: parseJalali(this.invoiceDate()).toISOString(),
payments: this.payments(),
total_amount: this.orderPricingInfo().totalAmount,
};
let res: Maybe<IPosOrderResponse> = null;
await this.service
.submitOrder(orderPayload)
.pipe(
finalize(() => {
this.setState({ submitOrderLoading: false });
}),
catchError(() => {
return throwError('');
}),
)
.subscribe((_res) => {
this.reset();
res = _res;
});
if (res) {
return new Observable<IPosOrderResponse>((observer) => observer.next(res!));
}
return new Observable((observer) => observer.error());
}
}
@@ -1 +1,8 @@
<span>صفحه‌ی pos</span>
<div class="flex gap-4 grow overflow-hidden h-full">
<div class="grow h-full overflow-auto">
<pos-goods />
</div>
<div class="shrink-0 h-full">
<pos-order-section />
</div>
</div>
@@ -1,21 +1,13 @@
// import { CatalogRoleTagComponent } from '@/shared/catalog/roles';
import { PosStore } from '@/domains/pos/pos.store';
import { LayoutService } from '@/layout/service/layout.service';
import { Component, computed, inject } from '@angular/core';
import { Component } from '@angular/core';
import { PosGoodsComponent } from '../components/goods.component';
import { PosOrderSectionComponent } from '../components/order/order-section.component';
@Component({
selector: 'pos-landing',
templateUrl: './root.component.html',
host: { class: 'grow overflow-hidden' },
imports: [PosGoodsComponent, PosOrderSectionComponent],
})
export class PosLandingComponent {
private readonly store = inject(PosStore);
private readonly layoutService = inject(LayoutService);
private readonly posInfo = computed(() => this.store.entity());
ngOnInit() {
this.layoutService.setPanelInfo({
title: `فروشگاه ${this.posInfo()?.complex.name} - ${this.posInfo()?.name}`,
});
}
}
export class PosLandingComponent {}
@@ -5,16 +5,21 @@ import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
template: '',
})
export abstract class AbstractDialog {
@Input()
@Input({ required: true })
set visible(v: boolean) {
this.visibleSignal.set(!!v);
this.visibleChange.emit(!!v);
if (!v) {
this.onClose.emit();
}
}
get visible() {
return this.visibleSignal();
}
private visibleSignal = signal(false);
@Output() onClose = new EventEmitter<void>();
close() {
this.visible = false;
}
@@ -1,7 +1,7 @@
import { TEnumApi } from '../models';
export const apiEnumTranslates = {
goldKarat: '',
goldKarat: 'عیار',
userStatus: 'وضعیت کاربر',
accountRole: 'نقش',
accountStatus: 'وضعیت',
@@ -16,6 +16,7 @@
[class]="` w-full ${size === 'large' ? 'p-inputtext-lg' : size === 'small' ? 'p-inputtext-sm' : ''}`"
[required]="isRequired"
[invalid]="control.invalid && (control.touched || control.dirty)"
(onInput)="onPriceInput($event)"
(onBlur)="blur.emit()"
/>
<!-- (input)="onInput($event)" -->
@@ -39,7 +40,9 @@
(input)="onInput($event)"
/>
}
@if (preparedSuffix()) {
@if (suffixTemp) {
<ng-container [ngTemplateOutlet]="suffixTemp" />
} @else if (preparedSuffix()) {
<p-inputgroup-addon>{{ preparedSuffix() }}</p-inputgroup-addon>
}
</p-inputgroup>
@@ -2,12 +2,21 @@ import { Maybe } from '@/core';
import { ToastService } from '@/core/services/toast.service';
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, computed, EventEmitter, inject, Input, Output } from '@angular/core';
import {
Component,
computed,
ContentChild,
EventEmitter,
inject,
Input,
Output,
TemplateRef,
} from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddon } from 'primeng/inputgroupaddon';
import { InputMaskModule } from 'primeng/inputmask';
import { InputNumberModule } from 'primeng/inputnumber';
import { InputNumberInputEvent, InputNumberModule } from 'primeng/inputnumber';
import { InputTextModule } from 'primeng/inputtext';
import { KeyFilterModule } from 'primeng/keyfilter';
import { ToggleSwitch } from 'primeng/toggleswitch';
@@ -33,6 +42,7 @@ export class InputComponent {
@Input() type:
| 'simple'
| 'postalCode'
| 'nationalId'
| 'mobile'
| 'phone'
| 'email'
@@ -58,6 +68,8 @@ export class InputComponent {
@Output() valueChange = new EventEmitter<string | number>();
@Output() blur = new EventEmitter<void>();
@ContentChild('suffixTemp', { static: true }) suffixTemp!: TemplateRef<any> | null;
private readonly toastService = inject(ToastService);
// onInput(ev: Event) {
@@ -80,6 +92,8 @@ export class InputComponent {
return '09xxxxxxxx';
case 'postalCode':
return 'کد پستی (۱۰ رقم)';
case 'nationalId':
return 'کد ملی (۱۰ رقم)';
case 'phone':
return 'شماره تلفن';
case 'email':
@@ -96,6 +110,7 @@ export class InputComponent {
case 'mobile':
case 'phone':
case 'postalCode':
case 'nationalId':
case 'price':
case 'number':
return 'numeric';
@@ -113,6 +128,7 @@ export class InputComponent {
case 'mobile':
return 11;
case 'postalCode':
case 'nationalId':
return 10;
case 'phone':
return 11;
@@ -132,6 +148,7 @@ export class InputComponent {
break;
case 'email':
case 'postalCode':
case 'nationalId':
inputClass.push('ltrInput', 'rtlPlaceholder');
break;
}
@@ -150,6 +167,7 @@ export class InputComponent {
case 'phone':
case 'price':
case 'postalCode':
case 'nationalId':
case 'number':
return 'number';
case 'checkbox':
@@ -165,11 +183,22 @@ export class InputComponent {
return this.required || this.control.hasValidator(Validators.required);
}
onInput($event: Event) {
onPriceInput($event: InputNumberInputEvent) {
console.log($event);
this.onInput($event.originalEvent, true);
}
onInput($event: Event, isPriceFormat?: boolean) {
// @ts-ignore
const value = $event.target.value as string;
if (this.type === 'number') {
let value = $event.target.value as string;
if (isPriceFormat) {
value = value.replace(/,/g, '');
}
if (this.inputMode === 'numeric') {
let newValue = parseFloat(value);
// console.log(value, newValue);
const minValidator = (this.min || this.min === 0) && parseFloat(value) < this.min!;
const maxValidator = (this.max || this.max === 0) && parseFloat(value) > this.max;
@@ -187,10 +216,20 @@ export class InputComponent {
}
newValue = parseFloat(value.slice(0, value.length - 1));
// @ts-ignore
$event.target.value = newValue;
if (newValue < 0 || isNaN(newValue)) {
newValue = 0;
}
if (isPriceFormat) {
// @ts-ignore
$event.target.value = newValue.toLocaleString('en-US');
} else {
// @ts-ignore
$event.target.value = newValue;
}
this.control.setValue(newValue);
}
this.control.setValue(newValue);
}
if (this.preparedMaxLength && this.preparedMaxLength < value.length) {
@@ -1,6 +1,17 @@
<div class="inline-flex gap-2 items-center w-full">
<span class="text-muted-color text-base font-normal shrink-0">{{ label }}:</span>
<ng-content>
<span class="text-text-color text-base font-bold grow">{{ value || "-" }}</span>
</ng-content>
<div class="inline-flex flex-col gap-1 w-full">
<div class="inline-flex gap-2 items-center w-full">
<span class="text-muted-color text-base font-normal flex-auto grow-0">{{ label }}:</span>
<div class="shrink-0 grow">
<ng-content>
@if (valueType() === "badge") {
<p-badge [value]="valueToShow()?.toString()" [severity]="value ? 'success' : 'danger'" />
} @else {
<span class="text-text-color text-base font-bold grow"> {{ valueToShow() }}</span>
}
</ng-content>
</div>
</div>
@if (hint) {
<span class="text-muted-color text-sm font-normal">{{ hint }}</span>
}
</div>
@@ -1,10 +1,13 @@
import { formatDurationToText, formatJalali, JALALI_DATE_FORMATS } from '@/utils';
import priceMaskUtils from '@/utils/price-mask.utils';
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { Component, computed, Input, signal } from '@angular/core';
import { Badge } from 'primeng/badge';
@Component({
selector: 'app-key-value',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, Badge],
templateUrl: './key-value.component.html',
host: {
class: 'w-full block',
@@ -12,5 +15,86 @@ import { Component, Input } from '@angular/core';
})
export class KeyValueComponent {
@Input() label!: string;
@Input() value?: string = '-';
@Input() value?: string | boolean | number;
@Input() hint?: string;
@Input() type:
| 'price'
| 'active'
| 'boolean'
| 'has'
| 'date'
| 'dateTime'
| 'duration'
| 'simple' = 'simple';
@Input() trueValueToShow?: string;
@Input() falseValueToShow?: string;
@Input() variant?: 'badge' | 'text';
valueToShow = signal(this.setValueToShow());
ngOnChanges() {
this.valueToShow.set(this.setValueToShow());
}
setValueToShow() {
let value = this.value;
if (this.type !== 'simple') {
if (this.value) {
if (this.trueValueToShow) {
value = this.trueValueToShow;
} else {
switch (this.type) {
case 'date':
if (typeof this.value === 'boolean') return '-';
// @ts-ignore
return formatJalali(this.value);
case 'dateTime':
if (typeof this.value === 'boolean') return '-';
// @ts-ignore
return formatJalali(this.value, JALALI_DATE_FORMATS.NUMERIC_WITH_TIME);
case 'duration':
return formatDurationToText(this.value as string);
case 'price':
return priceMaskUtils.formatWithCurrency(this.value as number);
case 'active':
value = 'فعال';
break;
case 'boolean':
value = 'بله';
break;
case 'has':
value = 'دارد';
break;
}
}
} else {
if (this.falseValueToShow) {
value = this.falseValueToShow;
} else {
switch (this.type) {
case 'active':
value = 'غیر فعال';
break;
case 'boolean':
value = 'خیر';
break;
case 'has':
value = 'ندارد';
break;
}
}
}
}
switch (this.type) {
case 'simple':
return this.value || '';
}
return value;
}
valueType = computed(() =>
this.variant || ['boolean', 'has', 'active'].includes(this.type) ? 'badge' : 'text',
);
}
@@ -0,0 +1,7 @@
export const CustomerType = {
INDIVIDUAL: 'INDIVIDUAL',
LEGAL: 'LEGAL',
UNKNOWN: 'UNKNOWN',
} as const;
export type CustomerType = (typeof CustomerType)[keyof typeof CustomerType];
@@ -0,0 +1,21 @@
import { ISelectItem } from '@/shared/abstractClasses';
export const goldKarat = {
KARAT_18: 'KARAT_18',
KARAT_21: 'KARAT_21',
KARAT_24: 'KARAT_24',
} as const;
export type TGoldKarat = (typeof goldKarat)[keyof typeof goldKarat];
const translates: Record<string, string> = {
KARAT_18: '18',
KARAT_21: '21',
KARAT_24: '24',
};
export const goldKaratSelect: ISelectItem[] = Object.keys(goldKarat).map((key) => ({
id: key,
name: translates[key],
}));
export const goldKaratLabel = 'عیار';
@@ -0,0 +1,8 @@
import { goldKaratLabel, goldKaratSelect } from './goldKarat';
export const LOCAL_ENUMS = {
gold: {
label: goldKaratLabel,
items: goldKaratSelect,
},
};
+2
View File
@@ -0,0 +1,2 @@
export * from './io';
export * from './types';
+4
View File
@@ -0,0 +1,4 @@
export interface IEnumResponse {
name: string;
value: string;
}
+3
View File
@@ -0,0 +1,3 @@
import { LOCAL_ENUMS } from '../constants';
export type TLocalEnum = keyof typeof LOCAL_ENUMS;
@@ -0,0 +1,12 @@
<uikit-field [label]="label()" [control]="control" [showLabel]="showLabel" [showErrors]="showErrors">
<p-select
[loading]="loading()"
[options]="items()"
optionLabel="name"
optionValue="value"
[formControl]="control"
[showClear]="showClear"
[filter]="true"
appendTo="body"
/>
</uikit-field>
@@ -0,0 +1,30 @@
import { UikitFieldComponent } from '@/uikit';
import { Component, computed, Input } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
import { Observable } from 'rxjs';
import { AbstractSelectComponent, ISelectItem } from '../abstractClasses';
import { LOCAL_ENUMS } from './constants';
import { TLocalEnum } from './models';
@Component({
selector: 'app-enum-select',
templateUrl: './select.component.html',
imports: [UikitFieldComponent, Select, ReactiveFormsModule],
})
export class EnumSelectComponent extends AbstractSelectComponent<ISelectItem, ISelectItem[]> {
@Input() type!: TLocalEnum;
@Input() customLabel?: string;
label = computed(() => this.customLabel ?? LOCAL_ENUMS[this.type].label);
override getDataService() {
return new Observable<ISelectItem[]>((observer) => {
return observer.next(LOCAL_ENUMS[this.type].items);
});
}
ngOnInit() {
this.getData();
}
}
+342
View File
@@ -0,0 +1,342 @@
import jalaliPlugin from '@zoomit/dayjs-jalali-plugin';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/fa';
import relativeTime from 'dayjs/plugin/relativeTime';
// Extend dayjs with plugins
dayjs.extend(jalaliPlugin);
dayjs.extend(relativeTime);
/**
* Common date format patterns for Jalali calendar
*/
export const JALALI_DATE_FORMATS = {
/** Full date with weekday: "پنج‌شنبه، ۱۳ شهریور ۱۳۹۷" */
FULL_WITH_WEEKDAY: 'dddd، DD MMMM YYYY',
/** Full date: "۱۳ شهریور ۱۳۹۷" */
FULL: 'DD MMMM YYYY',
/** Short date with month name: "۱۳ شهریور ۹۷" */
SHORT: 'DD MMMM YY',
/** Numeric date: "۱۳۹۷/۰۶/۱۳" */
NUMERIC: 'YYYY/MM/DD',
/** Numeric date with time: "۱۳۹۷/۰۶/۱۳ ۱۶:۳۰" */
NUMERIC_WITH_TIME: 'YYYY/MM/DD HH:mm',
/** Numeric date with full time: "۱۳۹۷/۰۶/۱۳ ۱۶:۳۰:۱۵" */
NUMERIC_WITH_FULL_TIME: 'YYYY/MM/DD HH:mm:ss',
/** Time only: "۱۶:۳۰" */
TIME: 'HH:mm',
/** Time with seconds: "۱۶:۳۰:۱۵" */
TIME_WITH_SECONDS: 'HH:mm:ss',
/** Month and year: "شهریور ۱۳۹۷" */
MONTH_YEAR: 'MMMM YYYY',
/** Day and month: "۱۳ شهریور" */
DAY_MONTH: 'DD MMMM',
} as const;
/**
* Common date format patterns for Gregorian calendar
*/
export const GREGORIAN_DATE_FORMATS = {
/** Full date with weekday: "Thursday, September 04 2018" */
FULL_WITH_WEEKDAY: 'dddd, MMMM DD YYYY',
/** Full date: "September 04 2018" */
FULL: 'MMMM DD YYYY',
/** Short date: "Sep 04 18" */
SHORT: 'MMM DD YY',
/** Numeric date: "2018-09-04" */
NUMERIC: 'YYYY-MM-DD',
/** Numeric date with time: "2018-09-04 16:30" */
NUMERIC_WITH_TIME: 'YYYY-MM-DD HH:mm',
/** Numeric date with full time: "2018-09-04 16:30:15" */
NUMERIC_WITH_FULL_TIME: 'YYYY-MM-DD HH:mm:ss',
/** Time only: "16:30" */
TIME: 'HH:mm',
/** Time with seconds: "16:30:15" */
TIME_WITH_SECONDS: 'HH:mm:ss',
/** Month and year: "September 2018" */
MONTH_YEAR: 'MMMM YYYY',
/** Day and month: "September 04" */
DAY_MONTH: 'MMMM DD',
} as const;
/**
* Format a date as Jalali (Persian) calendar in Farsi locale
*
* @param date - Date to format (string, Date, Dayjs, or timestamp)
* @param format - Format pattern (default: 'YYYY/MM/DD')
* @returns Formatted date string in Jalali calendar
*
* @example
* formatJalali('2018-09-04') // '1397/06/13'
* formatJalali('2018-09-04', JALALI_DATE_FORMATS.FULL) // '۱۳ شهریور ۱۳۹۷'
* formatJalali(new Date(), JALALI_DATE_FORMATS.FULL_WITH_WEEKDAY) // 'پنج‌شنبه، ۱۳ شهریور ۱۳۹۷'
*/
export function formatJalali(
date: string | Date | Dayjs | number,
format: string = JALALI_DATE_FORMATS.NUMERIC,
): string {
return dayjs(date).calendar('jalali').locale('fa').format(format);
}
/**
* Format a date as Gregorian calendar in English locale
*
* @param date - Date to format (string, Date, Dayjs, or timestamp)
* @param format - Format pattern (default: 'YYYY-MM-DD')
* @returns Formatted date string in Gregorian calendar
*
* @example
* formatGregorian('1397/06/13', GREGORIAN_DATE_FORMATS.FULL) // 'September 04 2018'
* formatGregorian(new Date(), GREGORIAN_DATE_FORMATS.NUMERIC_WITH_TIME) // '2018-09-04 16:30'
*/
export function formatGregorian(
date: string | Date | Dayjs | number,
format: string = GREGORIAN_DATE_FORMATS.NUMERIC,
): string {
return dayjs(date).calendar('gregory').locale('en').format(format);
}
/**
* Parse a Jalali date string and return a Dayjs instance
*
* @param jalaliDate - Jalali date string (e.g., '1398-10-17')
* @param format - Optional format pattern for parsing
* @returns Dayjs instance
*
* @example
* parseJalali('1398-10-17') // Dayjs instance
* parseJalali('1398/10/17') // Dayjs instance
*/
export function parseJalali(jalaliDate: string, format?: string): Dayjs {
return dayjs(jalaliDate, { jalali: true, ...(format && { format }) });
}
/**
* Convert a Gregorian date to Jalali format
*
* @param gregorianDate - Gregorian date string, Date object, or Dayjs instance
* @param format - Optional format pattern (default: 'YYYY/MM/DD')
* @returns Formatted Jalali date string
*
* @example
* gregorianToJalali('2018-09-04') // '1397/06/13'
* gregorianToJalali('2018-09-04', JALALI_DATE_FORMATS.FULL) // '۱۳ شهریور ۱۳۹۷'
*/
export function gregorianToJalali(
gregorianDate: string | Date | Dayjs,
format: string = JALALI_DATE_FORMATS.NUMERIC,
): string {
return dayjs(gregorianDate).calendar('jalali').locale('fa').format(format);
}
/**
* Convert a Jalali date to Gregorian format
*
* @param jalaliDate - Jalali date string (e.g., '1398-10-17')
* @param format - Optional format pattern (default: 'YYYY-MM-DD')
* @returns Formatted Gregorian date string
*
* @example
* jalaliToGregorian('1398-10-17') // '2020-01-07'
* jalaliToGregorian('1397/06/13', GREGORIAN_DATE_FORMATS.FULL) // 'September 04 2018'
*/
export function jalaliToGregorian(
jalaliDate: string,
format: string = GREGORIAN_DATE_FORMATS.NUMERIC,
): string {
return parseJalali(jalaliDate).calendar('gregory').locale('en').format(format);
}
/**
* Get current date and time in Jalali format
*
* @param format - Optional format pattern (default: 'YYYY/MM/DD HH:mm')
* @returns Current date and time in Jalali format
*
* @example
* nowJalali() // '1403/12/05 14:30'
* nowJalali(JALALI_DATE_FORMATS.FULL_WITH_WEEKDAY) // 'یکشنبه، ۵ اسفند ۱۴۰۳'
*/
export function nowJalali(format: string = JALALI_DATE_FORMATS.NUMERIC_WITH_TIME): string {
return dayjs().calendar('jalali').locale('fa').format(format);
}
/**
* Get current date and time in Gregorian format
*
* @param format - Optional format pattern (default: 'YYYY-MM-DD HH:mm')
* @returns Current date and time in Gregorian format
*
* @example
* nowGregorian() // '2026-02-24 14:30'
* nowGregorian(GREGORIAN_DATE_FORMATS.FULL_WITH_WEEKDAY) // 'Sunday, February 24 2026'
*/
export function nowGregorian(format: string = GREGORIAN_DATE_FORMATS.NUMERIC_WITH_TIME): string {
return dayjs().calendar('gregory').locale('en').format(format);
}
/**
* Format a time duration as a relative time string in Farsi
*
* @param date - Date to compare against current time
* @returns Relative time string in Farsi
*
* @example
* relativeTimeJalali(dayjs().subtract(2, 'day')) // '۲ روز پیش'
*/
export function relativeTimeJalali(date: string | Date | Dayjs | number): string {
return dayjs(date).locale('fa').fromNow();
}
/**
* Check if a date string is a valid Jalali date
*
* @param jalaliDate - Jalali date string
* @returns true if valid, false otherwise
*
* @example
* isValidJalaliDate('1398-10-17') // true
* isValidJalaliDate('1398-13-17') // false
*/
export function isValidJalaliDate(jalaliDate: string): boolean {
return parseJalali(jalaliDate).isValid();
}
/**
* Get the start of day for a date in Jalali calendar
*
* @param date - Date to get start of day for
* @returns Dayjs instance at start of day
*
* @example
* startOfDayJalali('1398-10-17') // Dayjs instance at 00:00:00
*/
export function startOfDayJalali(date: string | Date | Dayjs | number): Dayjs {
return dayjs(date).calendar('jalali').startOf('day');
}
/**
* Get the end of day for a date in Jalali calendar
*
* @param date - Date to get end of day for
* @returns Dayjs instance at end of day
*
* @example
* endOfDayJalali('1398-10-17') // Dayjs instance at 23:59:59
*/
export function endOfDayJalali(date: string | Date | Dayjs | number): Dayjs {
return dayjs(date).calendar('jalali').endOf('day');
}
/**
* Format a date with custom calendar and locale
*
* @param date - Date to format
* @param calendar - Calendar type ('jalali' | 'gregory')
* @param locale - Locale ('fa' | 'en')
* @param format - Format pattern
* @returns Formatted date string
*
* @example
* formatDate('2018-09-04', 'jalali', 'fa', 'DD MMMM YYYY') // '۱۳ شهریور ۱۳۹۷'
* formatDate('2018-09-04', 'gregory', 'en', 'MMMM DD YYYY') // 'September 04 2018'
*/
export function formatDate(
date: string | Date | Dayjs | number,
calendar: 'jalali' | 'gregory',
locale: 'fa' | 'en',
format: string,
): string {
return dayjs(date).calendar(calendar).locale(locale).format(format);
}
/**
* Get Jalali month name in Farsi
*
* @param monthNumber - Month number (1-12)
* @returns Month name in Farsi
*
* @example
* getJalaliMonthName(1) // 'فروردین'
* getJalaliMonthName(6) // 'شهریور'
*/
export function getJalaliMonthName(monthNumber: number): string {
const monthNames = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
];
return monthNames[monthNumber - 1] || '';
}
/**
* Get Jalali weekday name in Farsi
*
* @param dayNumber - Day number (0-6, where 0 is Saturday)
* @returns Weekday name in Farsi
*
* @example
* getJalaliWeekdayName(0) // 'شنبه'
* getJalaliWeekdayName(4) // 'چهارشنبه'
*/
export function getJalaliWeekdayName(dayNumber: number): string {
const weekdayNames = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه'];
return weekdayNames[dayNumber] || '';
}
export function jalaliDiff(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
unit: dayjs.OpUnitType = 'day',
): number {
return toJalali(date1).diff(toJalali(date2), unit);
}
export function isJalaliBefore(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
): boolean {
return toJalali(date1).isBefore(toJalali(date2));
}
export function isJalaliAfter(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
): boolean {
return toJalali(date1).isAfter(toJalali(date2));
}
export function toJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): Dayjs {
return dayjs(date).calendar('jalali');
}
export default {
formatJalali,
formatGregorian,
parseJalali,
gregorianToJalali,
jalaliToGregorian,
nowJalali,
nowGregorian,
relativeTimeJalali,
isValidJalaliDate,
startOfDayJalali,
endOfDayJalali,
formatDate,
getJalaliMonthName,
getJalaliWeekdayName,
jalaliDiff,
isJalaliBefore,
isJalaliAfter,
JALALI_DATE_FORMATS,
GREGORIAN_DATE_FORMATS,
};
+41
View File
@@ -0,0 +1,41 @@
export const UnitType = {
COUNT: 'COUNT',
GRAM: 'GRAM',
KILOGRAM: 'KILOGRAM',
MILLILITER: 'MILLILITER',
LITER: 'LITER',
METER: 'METER',
HOUR: 'HOUR',
} as const;
export type UnitType = (typeof UnitType)[keyof typeof UnitType];
export interface IUnitPreparedText {
quantitySymbolText: string;
quantityLabel: string;
}
const translates: Record<UnitType, string> = {
COUNT: 'تعداد',
GRAM: 'گرم',
KILOGRAM: 'کیلوگرم',
METER: 'متر',
MILLILITER: 'میلی‌لیتر',
LITER: 'لیتر',
HOUR: 'ساعت',
};
export function getGoodUnitTypeProperties(unitType: UnitType): IUnitPreparedText {
const data = {
quantitySymbolText: translates[unitType],
quantityLabel: '',
};
if (['COUNT'].includes(unitType)) {
data.quantityLabel = 'تعداد';
} else {
data.quantityLabel = 'مقدار';
}
return data;
}
+3 -1
View File
@@ -1,6 +1,8 @@
export * from './currency';
export * from './jalali-date.utils';
export * from './date-formatter.utils';
export * from './good-unit-types.utils';
export * from './name';
export * from './page-params.utils';
export * from './price-mask.utils';
export * from './time';
export * from './uuid.utils';
-43
View File
@@ -1,43 +0,0 @@
import dayjs, { Dayjs } from 'dayjs';
import jalaliday from 'jalaliday';
dayjs.extend(jalaliday);
export function toJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): Dayjs {
return dayjs(date).calendar('jalali');
}
export function fromJalali(jalaliDate: string): Dayjs {
// Parse Jalali date string (e.g., '1404-09-21')
return dayjs(jalaliDate, { jalali: true }).calendar('gregory');
}
export function formatJalali(date: string | number | Date | Dayjs, format = 'YYYY/MM/DD'): string {
return toJalali(date).locale('fa').format(format);
}
export function jalaliDiff(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
unit: dayjs.OpUnitType = 'day',
): number {
return toJalali(date1).diff(toJalali(date2), unit);
}
export function isJalaliBefore(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
): boolean {
return toJalali(date1).isBefore(toJalali(date2));
}
export function isJalaliAfter(
date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs,
): boolean {
return toJalali(date1).isAfter(toJalali(date2));
}
export function nowJalali(): Dayjs {
return dayjs().calendar('jalali');
}
+31 -1
View File
@@ -10,4 +10,34 @@ export function timeToSeconds(time: string): number {
return hours * 3600 + minutes * 60 + seconds;
}
export default { timeToSeconds };
/**
* Format duration string to Persian format
* Returns formatted string with hours, minutes, and/or seconds
* Excludes zero values from the output
*
* Examples:
* formatDurationToText("01:30:00") -> "1 ساعت و 30 دقیقه"
* formatDurationToText("00:30:00") -> "30 دقیقه"
* formatDurationToText("01:00:00") -> "1 ساعت"
* formatDurationToText("00:00:30") -> "30 ثانیه"
*/
export function formatDurationToText(time: string): string {
const [hours, minutes, seconds] = time.split(':').map(Number);
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours} ساعت`);
}
if (minutes > 0) {
parts.push(`${minutes} دقیقه`);
}
if (seconds > 0) {
parts.push(`${seconds} ثانیه`);
}
return parts.join(' و ');
}
export default { timeToSeconds, formatDurationToText };
+1
View File
@@ -0,0 +1 @@
export const createUUID = () => Date.now().toString(36) + Math.random().toString(36).slice(2);