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
@@ -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 {}