diff --git a/src/app.component.ts b/src/app.component.ts index 62820ab..5b4b9ae 100644 --- a/src/app.component.ts +++ b/src/app.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, HostListener } from '@angular/core'; import { RouterModule } from '@angular/router'; import { ConfirmDialog } from 'primeng/confirmdialog'; import { ToastModule } from 'primeng/toast'; @@ -8,9 +8,25 @@ import { ToastModule } from 'primeng/toast'; standalone: true, imports: [RouterModule, ToastModule, ConfirmDialog], template: ` - + `, }) -export class AppComponent {} +export class AppComponent { + toastPosition: 'top-center' | 'bottom-right' = 'bottom-right'; + + constructor() { + this.updateToastPosition(); + } + + @HostListener('window:resize') + onResize() { + this.updateToastPosition(); + } + + private updateToastPosition() { + this.toastPosition = + typeof window !== 'undefined' && window.innerWidth <= 768 ? 'top-center' : 'bottom-right'; + } +} diff --git a/src/app/core/services/native-bridge.service.ts b/src/app/core/services/native-bridge.service.ts index 1694f50..850d0c1 100644 --- a/src/app/core/services/native-bridge.service.ts +++ b/src/app/core/services/native-bridge.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; +import { Maybe } from '../models'; interface INativeBridgeHost { pay?: (payload: string) => unknown; @@ -8,8 +9,7 @@ interface INativeBridgeHost { export interface INativePayRequest { amount: number; - totalAmount: number; - invoiceDate: string; + id: Maybe; } export interface INativePrintRequest { @@ -55,9 +55,9 @@ export class NativeBridgeService { } private invoke(method: 'pay' | 'print', payload: object): INativeBridgeResult { - if (!this.isEnabled()) { - return { success: false, error: 'Native bridge is disabled for this tenant.' }; - } + // if (!this.isEnabled()) { + // return { success: false, error: 'Native bridge is disabled for this tenant.' }; + // } const fn = this.host?.[method]; if (typeof fn !== 'function') { diff --git a/src/app/core/validators/greater.validator.ts b/src/app/core/validators/greater.validator.ts new file mode 100644 index 0000000..1de3bf7 --- /dev/null +++ b/src/app/core/validators/greater.validator.ts @@ -0,0 +1,25 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +/** + * Strictly checks numeric value is greater than `minValue` (not equal). + * Empty values are treated as valid; combine with `Validators.required` when needed. + */ +export function greaterThanValidator(minValue: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const v = control.value; + if (v === null || v === undefined || v === '') { + return null; + } + + const numericValue = typeof v === 'number' ? v : Number(v); + if (Number.isNaN(numericValue)) { + return { greaterThan: 'مقدار باید عددی باشد' }; + } + + return numericValue > minValue + ? null + : { + greaterThan: `مقدار باید بیشتر از ${minValue} باشد`, + }; + }; +} diff --git a/src/app/core/validators/index.ts b/src/app/core/validators/index.ts index 66965fa..fc9a5c2 100644 --- a/src/app/core/validators/index.ts +++ b/src/app/core/validators/index.ts @@ -1,4 +1,5 @@ export * from './fiscal-code.validator'; +export * from './greater.validator'; export * from './iban.validator'; export * from './mobile.validator'; export * from './must-match.validator'; diff --git a/src/app/domains/pos/layouts/layout.component.html b/src/app/domains/pos/layouts/layout.component.html index 8f7991c..6fd6811 100644 --- a/src/app/domains/pos/layouts/layout.component.html +++ b/src/app/domains/pos/layouts/layout.component.html @@ -1,8 +1,31 @@ + + + + + + @if (posInfo()) { +
+
+ Logo +
+ {{ posInfo()?.partner?.name }} +
+ } +
+ + + + + @if (loading()) { } @else { -
-
+ + + @if (error()) { + @switch (error()?.status) { + @case (412) { + + } + @default { +
+ +
+ + عدم دسترسی +

متاسفانه امکان دسترسی برای کاربر شما فراهم نیست

+ +
+
+
} - } @else { - } -
+ } @else { + + + } + } diff --git a/src/app/domains/pos/layouts/layout.component.ts b/src/app/domains/pos/layouts/layout.component.ts index 70b9c55..c413aa0 100644 --- a/src/app/domains/pos/layouts/layout.component.ts +++ b/src/app/domains/pos/layouts/layout.component.ts @@ -1,15 +1,25 @@ import { AuthService } from '@/core'; +import { LayoutService } from '@/layout/service/layout.service'; import { PageLoadingComponent } from '@/shared/components/page-loading.component'; import { JalaliDateDirective } from '@/shared/directives'; -import { Component, computed, inject } from '@angular/core'; +import { + AfterViewInit, + Component, + TemplateRef, + ViewChild, + computed, + inject, + signal, +} from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { MenuItem } from 'primeng/api'; -import { ButtonDirective } from 'primeng/button'; +import { Button, ButtonDirective } from 'primeng/button'; import { Card } from 'primeng/card'; import { Menu } from 'primeng/menu'; import images from 'src/assets/images'; import { PosInfoStore, PosProfileStore } from '../store'; import { PosChooseCardsComponent } from './choose-pos.component'; +import { PosMainMenuSidebarComponent } from './mainMenuSidebar/main-menu-sidebar.component'; @Component({ selector: 'pos-layout', @@ -22,14 +32,22 @@ import { PosChooseCardsComponent } from './choose-pos.component'; ButtonDirective, Card, PosChooseCardsComponent, + Button, + PosMainMenuSidebarComponent, ], }) -export class PosLayoutComponent { +export class PosLayoutComponent implements AfterViewInit { constructor() {} + @ViewChild('topbarStart', { static: true }) topbarStart!: TemplateRef; + @ViewChild('topbarCenter', { static: true }) topbarCenter!: TemplateRef; + @ViewChild('topbarEnd', { static: true }) topbarEnd!: TemplateRef; private readonly posProfileStore = inject(PosProfileStore); private readonly posInfoStore = inject(PosInfoStore); private readonly authService = inject(AuthService); + private readonly layoutService = inject(LayoutService); + + mainMenuVisible = signal(false); readonly posProfileLoading = computed(() => this.posProfileStore.loading()); readonly posProfile = computed(() => this.posProfileStore.entity()); @@ -71,11 +89,28 @@ export class PosLayoutComponent { }); } + toggleMenu() { + this.mainMenuVisible.update((v) => !v); + } + onChoosePos() { this.getData(); } ngOnInit() { + this.layoutService.changeIsFullPage(true); this.getData(); } + + ngAfterViewInit() { + this.layoutService.setTopbarStartSlot(this.topbarStart); + this.layoutService.setTopbarCenterSlot(this.topbarCenter); + this.layoutService.setTopbarEndSlot(this.topbarEnd); + } + + ngOnDestroy() { + this.layoutService.setTopbarStartSlot(null); + this.layoutService.setTopbarCenterSlot(null); + this.layoutService.setTopbarEndSlot(null); + } } diff --git a/src/app/domains/pos/layouts/mainMenuSidebar/main-menu-sidebar.component.html b/src/app/domains/pos/layouts/mainMenuSidebar/main-menu-sidebar.component.html new file mode 100644 index 0000000..68ba4e0 --- /dev/null +++ b/src/app/domains/pos/layouts/mainMenuSidebar/main-menu-sidebar.component.html @@ -0,0 +1,67 @@ + + +
+
+
+ {{ posInfo()?.name }} + + {{ posInfo()?.businessActivity?.name }} - {{ posInfo()?.complex?.name }} + +
+ + + +
+
+ + +
+
+ @if (isPwaBuild) { +
+ نسخه برنامه : {{ appVersion }} +
+ } +
+
+
+
diff --git a/src/app/domains/pos/layouts/mainMenuSidebar/main-menu-sidebar.component.ts b/src/app/domains/pos/layouts/mainMenuSidebar/main-menu-sidebar.component.ts new file mode 100644 index 0000000..d245537 --- /dev/null +++ b/src/app/domains/pos/layouts/mainMenuSidebar/main-menu-sidebar.component.ts @@ -0,0 +1,51 @@ +import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog'; +import { Component, computed, inject } from '@angular/core'; +import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; +import { Button } from 'primeng/button'; +import { Drawer } from 'primeng/drawer'; +import { Ripple } from 'primeng/ripple'; +import { filter } from 'rxjs'; +import { PosInfoStore } from '../../store'; + +@Component({ + selector: 'pos-main-menu-sidebar', + templateUrl: './main-menu-sidebar.component.html', + imports: [Drawer, Button, Ripple], +}) +export class PosMainMenuSidebarComponent extends AbstractDialog { + private readonly posInfoStore = inject(PosInfoStore); + private readonly swUpdate = inject(SwUpdate); + + readonly posInfo = computed(() => this.posInfoStore.entity()); + appVersion = '0.0.0'; + isPwaBuild = false; + + readonly posName = computed(() => { + if (this.posInfo()) { + const { name, businessActivity, complex } = this.posInfo()!; + return `${name} (${businessActivity.name} - ${complex.name})`; + } + return ''; + }); + + ngOnInit() { + this.isPwaBuild = this.swUpdate.isEnabled; + if (!this.isPwaBuild) return; + + fetch('/ngsw.json') + .then((res) => res.json()) + .then((data: { appData?: { appVersion?: string } }) => { + this.appVersion = data?.appData?.appVersion || this.appVersion; + }) + .catch(() => {}); + + this.swUpdate.versionUpdates + .pipe(filter((event): event is VersionReadyEvent => event.type === 'VERSION_READY')) + .subscribe((event) => { + const appData = event.latestVersion.appData as { appVersion?: string } | undefined; + this.appVersion = appData?.appVersion || this.appVersion; + }); + + this.swUpdate.checkForUpdate().catch(() => {}); + } +} diff --git a/src/app/domains/pos/models/pos.io.ts b/src/app/domains/pos/models/pos.io.ts index 82ca4c2..2b0605c 100644 --- a/src/app/domains/pos/models/pos.io.ts +++ b/src/app/domains/pos/models/pos.io.ts @@ -2,13 +2,11 @@ import ISummary from '@/core/models/summary'; export interface IPosInfoRawResponse { name: string; - complex: { - id: string; - name: string; - branch_code: string; - }; - businessActivity: ISummary; - guild: ISummary; + complex: Complex; + businessActivity: BusinessActivity; + guild: Guild; + license_info: LicenseInfo; + partner: Partner; } export interface IPosInfoResponse extends IPosInfoRawResponse {} @@ -21,3 +19,25 @@ export interface IPosAccessibleResponse extends IPosAccessibleRawResponse {} interface Complex extends ISummary { business_activity: ISummary; } + +interface LicenseInfo { + expires_at: string; + license_id: string; +} + +interface Guild extends ISummary { + code: string; +} + +interface Partner extends ISummary { + code: string; + logo_url?: string; +} + +interface BusinessActivity extends ISummary { + economic_code: string; +} + +interface Complex extends ISummary { + branch_code: string; +} diff --git a/src/app/domains/pos/modules/landing/components/customers/customer-dialog.component.ts b/src/app/domains/pos/modules/landing/components/customers/customer-dialog.component.ts index 437f353..9b2eb2e 100644 --- a/src/app/domains/pos/modules/landing/components/customers/customer-dialog.component.ts +++ b/src/app/domains/pos/modules/landing/components/customers/customer-dialog.component.ts @@ -1,4 +1,5 @@ import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog'; +import { SharedDialogComponent } from '@/shared/components/dialog/dialog.component'; import { CustomerType } from '@/shared/localEnum/constants/customerTypes'; import { Component, EventEmitter, inject, Output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -7,7 +8,6 @@ import { PosLandingStore } from '../../store/main.store'; import { CustomerIndividualFormComponent } from './individual/form.component'; import { CustomerLegalFormComponent } from './legal/form.component'; import { CustomerUnknownFormComponent } from './unknown/form.component'; -import { SharedDialogComponent } from '@/shared/components/dialog/dialog.component'; @Component({ selector: 'pos-order-customer-dialog', @@ -28,15 +28,15 @@ export class PosOrderCustomerDialogComponent extends AbstractDialog { customerTypes = [ { - label: 'بدون اطلاعات خریدار (نوع دوم)', + label: 'بدون اطلاعات (نوع دوم)', value: 'UNKNOWN', }, { - label: 'خریدار حقیقی (نوع اول)', + label: 'حقیقی (نوع اول)', value: 'INDIVIDUAL', }, { - label: 'خریدار حقوقی (نوع اول)', + label: 'حقوقی (نوع اول)', value: 'LEGAL', }, ] as { diff --git a/src/app/domains/pos/modules/landing/components/goods.component.html b/src/app/domains/pos/modules/landing/components/goods.component.html index c7559a1..caccbe3 100644 --- a/src/app/domains/pos/modules/landing/components/goods.component.html +++ b/src/app/domains/pos/modules/landing/components/goods.component.html @@ -1,29 +1,40 @@ -
-
-
-
+
+
+
+ +
+ + + + + + +
- @if (viewType === "grid") { - - } @else { - - } +
+ @if (!loading() && !goods()?.length) { +
+ + کالایی برای نمایش وجود ندارد. +
+ } @else if (viewType === "grid") { + + } @else { + + } +
@if (selectedGoodToAdd()) { this.store.activatedCategoryGoods()); readonly stock = computed(() => this.store.goods()); + readonly inOrderGoods = computed(() => this.store.inOrderGoods()); + readonly priceInfo = computed(() => this.store.orderPricingInfo()); + readonly goods = computed(() => this.store.filteredGoods()); + readonly loading = computed(() => this.store.getGoodsLoading()); get viewType() { return this.store.viewType(); @@ -52,7 +58,7 @@ export class PosGoodsComponent { onSearch(searchTerm: string) { this.store.updateSearchQuery(searchTerm); - this.store.getGoods(); + this.store.filterGoods(); } onCategoryChange(categoryId: string) { diff --git a/src/app/domains/pos/modules/landing/components/grid-view.component.html b/src/app/domains/pos/modules/landing/components/grid-view.component.html index 13f8df0..b5797c7 100644 --- a/src/app/domains/pos/modules/landing/components/grid-view.component.html +++ b/src/app/domains/pos/modules/landing/components/grid-view.component.html @@ -7,10 +7,10 @@ } } @else { @for (good of goods(); track good.id) { -
+
-
- +
+ {{ good.name }} @if (!good.is_default_guild_good) { * @@ -18,9 +18,7 @@
- +
diff --git a/src/app/domains/pos/modules/landing/components/grid-view.component.ts b/src/app/domains/pos/modules/landing/components/grid-view.component.ts index e64b5e8..41ab119 100644 --- a/src/app/domains/pos/modules/landing/components/grid-view.component.ts +++ b/src/app/domains/pos/modules/landing/components/grid-view.component.ts @@ -14,7 +14,7 @@ export class PosGoodsGridViewComponent { private readonly store = inject(PosLandingStore); @Output() onAdd = new EventEmitter(); - goods = computed(() => this.store.activatedCategoryGoods()); + goods = computed(() => this.store.filteredGoods()); loading = computed(() => this.store.getGoodsLoading()); goodPlaceholder = images.placeholders.default; diff --git a/src/app/domains/pos/modules/landing/components/list-view.component.ts b/src/app/domains/pos/modules/landing/components/list-view.component.ts index ae08665..32d205a 100644 --- a/src/app/domains/pos/modules/landing/components/list-view.component.ts +++ b/src/app/domains/pos/modules/landing/components/list-view.component.ts @@ -14,7 +14,7 @@ export class PosGoodsListViewComponent { private readonly store = inject(PosLandingStore); @Output() onAdd = new EventEmitter(); - goods = computed(() => this.store.activatedCategoryGoods()); + goods = computed(() => this.store.filteredGoods()); loading = computed(() => this.store.getGoodsLoading()); goodPlaceholder = images.placeholders.default; diff --git a/src/app/domains/pos/modules/landing/components/order/order-card-template.component.html b/src/app/domains/pos/modules/landing/components/order/order-card-template.component.html index 6ebcb42..461edd0 100644 --- a/src/app/domains/pos/modules/landing/components/order/order-card-template.component.html +++ b/src/app/domains/pos/modules/landing/components/order/order-card-template.component.html @@ -12,9 +12,6 @@ {{ inOrderGood.good.name }} -
- ({{ inOrderGood.good.sku }}) -
{{ inOrderGood.quantity }} {{ enumTranslator(inOrderGood.good.unit_type) }} diff --git a/src/app/domains/pos/modules/landing/components/order/order-section.component.html b/src/app/domains/pos/modules/landing/components/order/order-section.component.html index d8baa15..2ba033e 100644 --- a/src/app/domains/pos/modules/landing/components/order/order-section.component.html +++ b/src/app/domains/pos/modules/landing/components/order/order-section.component.html @@ -1,16 +1,29 @@ -
+
سفارش‌ها
- +
+ + +
@if (!inOrderGoods().length) { -
+
هیچ سفارشی ثبت نشده است.
@@ -42,7 +55,7 @@ severity="primary" icon="pi pi-check" class="w-full" - [attr.disabled]="inOrderGoods().length === 0 ? true : null" + size="large" (click)="submitAndPay()" >
diff --git a/src/app/domains/pos/modules/landing/components/order/order-section.component.ts b/src/app/domains/pos/modules/landing/components/order/order-section.component.ts index 48269af..171637b 100644 --- a/src/app/domains/pos/modules/landing/components/order/order-section.component.ts +++ b/src/app/domains/pos/modules/landing/components/order/order-section.component.ts @@ -1,7 +1,7 @@ // 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 { Component, computed, EventEmitter, inject, Output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonDirective } from 'primeng/button'; import { Card } from 'primeng/card'; @@ -30,6 +30,7 @@ import { POSOrderPriceInfoCardComponent } from './price-info-card.component'; }) export class PosOrderSectionComponent { private readonly store = inject(PosLandingStore); + @Output() onAddMoreGoods = new EventEmitter(); placeholderImage = images.placeholders.default; @@ -77,6 +78,10 @@ export class PosOrderSectionComponent { this.isVisiblePaymentForm.set(true); } + addMoreGoods() { + this.onAddMoreGoods.emit(); + } + submitCustomer() {} submitPayment() {} } diff --git a/src/app/domains/pos/modules/landing/components/payload-form.component.html b/src/app/domains/pos/modules/landing/components/payload-form.component.html index 634398a..29cf5f3 100644 --- a/src/app/domains/pos/modules/landing/components/payload-form.component.html +++ b/src/app/domains/pos/modules/landing/components/payload-form.component.html @@ -8,11 +8,12 @@ > @if (good) { @if (isGoldMode()) { - + } @else if (isStandardMode()) { } diff --git a/src/app/domains/pos/modules/landing/components/payload-form.component.ts b/src/app/domains/pos/modules/landing/components/payload-form.component.ts index b049040..50991fe 100644 --- a/src/app/domains/pos/modules/landing/components/payload-form.component.ts +++ b/src/app/domains/pos/modules/landing/components/payload-form.component.ts @@ -1,11 +1,11 @@ import { IGoodResponse } from '@/domains/pos/models/good.io'; import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog'; +import { SharedDialogComponent } from '@/shared/components/dialog/dialog.component'; import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core'; 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'; -import { SharedDialogComponent } from '@/shared/components/dialog/dialog.component'; @Component({ selector: 'pos-payload-form-dialog', diff --git a/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.html b/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.html index 3a267d8..e8f12d0 100644 --- a/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.html +++ b/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.html @@ -1,35 +1,37 @@ -
- - - +
+ + + + - - - + + + + - افزودن به لیست خرید +
- +
diff --git a/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.ts b/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.ts index acbd92e..e3e2cb0 100644 --- a/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.ts +++ b/src/app/domains/pos/modules/landing/components/payloads/gold/form.component.ts @@ -1,12 +1,13 @@ +import { greaterThanValidator } from '@/core/validators/greater.validator'; import { AbstractForm } from '@/shared/abstractClasses'; import { EnumSelectComponent } from '@/shared/apiEnum/select.component'; import { InputComponent } from '@/shared/components'; import { AmountPercentageInputComponent } from '@/shared/components/amountPercentageInput/amount-percentage-input.component'; 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 { Component, computed, EventEmitter, Output, signal } from '@angular/core'; +import { FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ButtonDirective } from 'primeng/button'; import { SelectButton, SelectButtonChangeEvent } from 'primeng/selectbutton'; import { IGoldPayload, IPosOrderItem } from '../../../models'; import { PosFormDialogAmountCardTemplateComponent } from '../form-dialog-amount-card-template.component'; @@ -15,13 +16,14 @@ import { PosFormDialogAmountCardTemplateComponent } from '../form-dialog-amount- selector: 'pos-gold-payload-form', templateUrl: './form.component.html', imports: [ + FormsModule, ReactiveFormsModule, InputComponent, EnumSelectComponent, - Button, PosFormDialogAmountCardTemplateComponent, SelectButton, AmountPercentageInputComponent, + ButtonDirective, ], }) export class PosGoldPayloadFormComponent extends AbstractForm< @@ -50,10 +52,12 @@ export class PosGoldPayloadFormComponent extends AbstractForm< taxAmount = signal(0); totalAmount = signal(0); + preparedCTAText = computed(() => (this.editMode ? 'اعمال ویرایش' : 'افزودن به لیست خرید')); + private readonly initialForm = () => { const form = this.fb.group({ unit_price: [this.initialValues?.unit_price || 0, [Validators.required]], - quantity: [this.initialValues?.quantity || 0, [Validators.required]], + quantity: [this.initialValues?.quantity || 0, [Validators.required, greaterThanValidator(0)]], discount_amount: [this.initialValues?.discount || 0], discount_percentage: [0, [Validators.max(100), Validators.min(0)]], payload: this.fb.group({ diff --git a/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.html b/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.html index cf1c5e8..ddce49b 100644 --- a/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.html +++ b/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.html @@ -27,6 +27,6 @@ [discountAmount]="discountAmount()" [taxAmount]="taxAmount()" /> - افزودن به لیست خرید + {{ preparedCTAText() }}
diff --git a/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.ts b/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.ts index b15a7df..ac7f3fd 100644 --- a/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.ts +++ b/src/app/domains/pos/modules/landing/components/payloads/standard/form.component.ts @@ -59,6 +59,8 @@ export class PosStandardPayloadFormComponent extends AbstractForm< discountAmount = signal(0); taxAmount = signal(0); + preparedCTAText = computed(() => (this.editMode ? 'اعمال ویرایش' : 'افزودن به لیست خرید')); + toPriceFormat(amount: number) { if (!amount) { return ''; diff --git a/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.html b/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.html index a6c4925..dbc3392 100644 --- a/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.html +++ b/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.html @@ -6,43 +6,69 @@ [closable]="true" (onHide)="close()" > -
- - -
+
+ + + +
+ - - - - - - - - - - - - - - - +
+ + + + @for (terminalControl of terminalControls; track $index) { + + + + + + } +
+
+ + + + + + + + + + - - + + +
diff --git a/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.ts b/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.ts index 17acbe0..b37a41e 100644 --- a/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.ts +++ b/src/app/domains/pos/modules/landing/components/payment/form-dialog.component.ts @@ -1,31 +1,37 @@ -import { NativeBridgeService } from '@/core/services'; 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 { catchError, throwError } from 'rxjs'; -import { IPayment, TOrderPaymentTypes } from '../../models'; -import { PosLandingStore } from '../../store/main.store'; import { SharedDialogComponent } from '@/shared/components/dialog/dialog.component'; +import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component'; +import { UikitFieldComponent } from '@/uikit'; +import { Component, computed, inject, signal } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ButtonDirective } from 'primeng/button'; +import { Select } from 'primeng/select'; +import { IPayment, TOrderPaymentTypes } from '../../models'; +import { PosPaymentBridgeAbstract } from '../../services/payment-bridge.abstract'; +import { PosPaymentBridgeService } from '../../services/payment-bridge.service'; +import { PosLandingStore } from '../../store/main.store'; @Component({ selector: 'pos-payment-form-dialog', templateUrl: './form-dialog.component.html', imports: [ + FormsModule, ReactiveFormsModule, InputComponent, FormFooterActionsComponent, KeyValueComponent, ButtonDirective, SharedDialogComponent, + Select, + UikitFieldComponent, ], + providers: [{ provide: PosPaymentBridgeAbstract, useExisting: PosPaymentBridgeService }], }) export class PosPaymentFormDialogComponent extends AbstractFormDialog { private readonly store = inject(PosLandingStore); - private readonly nativeBridge = inject(NativeBridgeService); + private readonly paymentBridge = inject(PosPaymentBridgeAbstract); private readonly toastServices = inject(ToastService); readonly totalAmount = computed(() => this.store.orderPricingInfo().totalAmount); @@ -33,35 +39,79 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog this.store.submitOrderLoading()); + payByTerminalSteps = Array.from({ length: 10 }, (_, i) => ({ + value: i + 1, + label: `${i + 1} مرحله‌ای`, + })); + + selectedPayByTerminalStep = signal(1); + payedInTerminal = signal>([]); + private initForm = () => { const form = this.fb.group({ set_off: [this.initialValues?.set_off || 0], cash: [this.initialValues?.cash || 0], - terminal: [this.initialValues?.terminal || 0], + terminals: this.fb.array([this.fb.control(0)]), }); form.valueChanges.subscribe((value) => { - const totalValue = (value.cash || 0) + (value.set_off || 0) + (value.terminal || 0); + const terminalTotal = + (value.terminals || []).reduce((acc, curr) => (acc || 0) + (curr || 0), 0) || 0; + + const totalValue = (value.cash || 0) + (value.set_off || 0) + terminalTotal; 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)]); + if (!setOffMax) { + form.controls.set_off.disable({ + onlySelf: true, + }); + } else { + form.controls.set_off.enable({ + onlySelf: true, + }); + } + form.controls.cash.clearValidators(); form.controls.cash.addValidators([Validators.max(cashMax)]); - form.controls.terminal.clearValidators(); - form.controls.terminal.addValidators([Validators.max(terminalMax)]); + if (!cashMax) { + form.controls.cash.disable({ + onlySelf: true, + }); + } else { + form.controls.cash.enable({ + onlySelf: true, + }); + } + + form.controls.terminals.controls.forEach((terminal, i) => { + terminal.clearValidators(); + this.terminalsMax.update((max) => { + const terminalMax = remainedAmount + (terminal.value || 0); + terminal.addValidators([Validators.max(terminalMax)]); + if (terminalMax) { + terminal.enable({ + onlySelf: true, + }); + } else { + terminal.disable({ + onlySelf: true, + }); + } + return max.map((m, index) => (index === i ? terminalMax : m)); + }); + }); this.remainedAmount.set(remainedAmount); this.cashMax.set(cashMax); - this.terminalMax.set(terminalMax); this.setOffMax.set(setOffMax); }); @@ -69,13 +119,55 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog[]; + } - fillRemained(type: TOrderPaymentTypes) { + changePayByTerminalSteps(steps: number) { + const normalizedSteps = Math.max(1, Number(steps) || 1); + this.selectedPayByTerminalStep.set(normalizedSteps); + + const terminals = this.form.controls.terminals; + const currentLength = terminals.length; + + if (normalizedSteps > currentLength) { + for (let i = currentLength; i < normalizedSteps; i += 1) { + terminals.push(this.fb.control(0)); + const terminalMax = this.remainedAmount(); + this.form.controls.terminals.controls[i].addValidators([Validators.max(terminalMax)]); + this.terminalsMax.update((max) => [...max, terminalMax]); + } + return; + } + + if (normalizedSteps < currentLength) { + const removedSum = terminals.controls + .slice(normalizedSteps) + .reduce((acc, control) => acc + (control.value || 0), 0); + + this.remainedAmount.update((value) => value + removedSum); + + while (terminals.length > normalizedSteps) { + terminals.removeAt(terminals.length - 1); + } + + // terminals.at(0)?.setValue(firstValue + removedSum); + } + } + + fillTerminalRemained(index: number) { + this.fillRemained('TERMINAL', index); + } + + fillRemained(type: TOrderPaymentTypes, _terminalIndex?: number) { switch (type) { case 'TERMINAL': - this.form.controls.terminal.setValue( - (this.form.controls.terminal.value || 0) + this.remainedAmount(), - ); + const terminalIndex = _terminalIndex ?? 0; + this.form.controls.terminals + .at(terminalIndex) + ?.setValue( + (this.form.controls.terminals.at(terminalIndex)?.value || 0) + this.remainedAmount(), + ); break; case 'CASH': @@ -101,43 +193,50 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog Number(value || 0)), + }; - if (payment.terminal > 0 && this.nativeBridge.isEnabled()) { - const payResult = this.nativeBridge.pay({ - amount: payment.terminal, - totalAmount: this.totalAmount(), - invoiceDate: new Date().toISOString(), - }); - - if (!payResult.success) { - return this.toastServices.warn({ - text: payResult.error || 'پرداخت با دستگاه انجام نشد. لطفا دوباره تلاش کنید.', + if (payment.terminals.some((terminal) => terminal > 0) && this.paymentBridge.isEnabled()) { + for (let terminal of payment.terminals) { + if (terminal <= 0) continue; + const payResult = this.paymentBridge.pay({ + amount: terminal, + id: '0', }); + + if (!payResult.success) { + return this.toastServices.warn({ + text: payResult.error || 'پرداخت با دستگاه انجام نشد. لطفا دوباره تلاش کنید.', + }); + } } } - this.store.setPayment(payment); + // this.store.setPayment(payment); - this.store - .submitOrder() - .pipe( - catchError((err) => { - return throwError(() => err); - }), - ) - .subscribe((res) => { - if (this.nativeBridge.isEnabled()) { - this.nativeBridge.print({ - invoiceId: res?.id, - code: res?.code, - }); - } - this.close(); - this.toastServices.success({ - text: 'فاکتور شما با موفقیت ایجاد شد', - }); - }); + // this.store + // .submitOrder() + // .pipe( + // catchError((err) => { + // return throwError(() => err); + // }), + // ) + // .subscribe((res) => { + // if (this.paymentBridge.isEnabled()) { + // this.paymentBridge.print({ + // invoiceId: res?.id, + // code: res?.code, + // }); + // } + // this.close(); + // this.toastServices.success({ + // text: 'فاکتور شما با موفقیت ایجاد شد', + // }); + // }); } override onSuccess(response: IPayment): void {} diff --git a/src/app/domains/pos/modules/landing/models/payment.ts b/src/app/domains/pos/modules/landing/models/payment.ts index 95e3d4c..e8f0e56 100644 --- a/src/app/domains/pos/modules/landing/models/payment.ts +++ b/src/app/domains/pos/modules/landing/models/payment.ts @@ -1,7 +1,18 @@ export interface IPayment { - terminal: number; + terminals: number[]; cash: number; set_off: number; } export type TOrderPaymentTypes = 'TERMINAL' | 'CASH' | 'SET_OFF'; + +export interface IPaymentTerminal { + amount: number; + terminal_id: string; + stan: string; + rrn: string; + response_code: string; + customer_card_no: string; + transaction_date_time: Date; + description?: string; +} diff --git a/src/app/domains/pos/modules/landing/services/main.service.ts b/src/app/domains/pos/modules/landing/services/main.service.ts index 6d9607e..134ca5e 100644 --- a/src/app/domains/pos/modules/landing/services/main.service.ts +++ b/src/app/domains/pos/modules/landing/services/main.service.ts @@ -37,7 +37,9 @@ export class PosService { } getGoods(searchQuery: string): Observable> { - return this.http.get>(this.apiRoutes.goods()); + return this.http.get>(this.apiRoutes.goods(), { + params: { search: searchQuery }, + }); } getGoodCategories(): Observable> { diff --git a/src/app/domains/pos/modules/landing/services/payment-bridge.abstract.ts b/src/app/domains/pos/modules/landing/services/payment-bridge.abstract.ts new file mode 100644 index 0000000..d0917e9 --- /dev/null +++ b/src/app/domains/pos/modules/landing/services/payment-bridge.abstract.ts @@ -0,0 +1,11 @@ +import { INativeBridgeResult, INativePayRequest, INativePrintRequest } from '@/core/services/native-bridge.service'; + +export abstract class PosPaymentBridgeAbstract { + abstract isEnabled(): boolean; + abstract canPay(): boolean; + abstract canPrint(): boolean; + abstract pay(request: INativePayRequest): INativeBridgeResult; + abstract print(request: INativePrintRequest): INativeBridgeResult; + abstract registerPaymentResultListener(handler: (payload: unknown) => void): () => void; + abstract emitPaymentResultForTest(payload: unknown): void; +} diff --git a/src/app/domains/pos/modules/landing/services/payment-bridge.service.ts b/src/app/domains/pos/modules/landing/services/payment-bridge.service.ts new file mode 100644 index 0000000..81f8078 --- /dev/null +++ b/src/app/domains/pos/modules/landing/services/payment-bridge.service.ts @@ -0,0 +1,62 @@ +import { NativeBridgeService } from '@/core/services'; +import { + INativeBridgeResult, + INativePayRequest, + INativePrintRequest, +} from '@/core/services/native-bridge.service'; +import { Injectable, inject } from '@angular/core'; +import { PosPaymentBridgeAbstract } from './payment-bridge.abstract'; + +@Injectable({ providedIn: 'root' }) +export class PosPaymentBridgeService extends PosPaymentBridgeAbstract { + private readonly nativeBridge = inject(NativeBridgeService); + + isEnabled(): boolean { + return this.nativeBridge.isEnabled(); + } + + canPay(): boolean { + return this.nativeBridge.canPay(); + } + + canPrint(): boolean { + return this.nativeBridge.canPrint(); + } + + pay(request: INativePayRequest): INativeBridgeResult { + return this.nativeBridge.pay(request); + } + + print(request: INativePrintRequest): INativeBridgeResult { + return this.nativeBridge.print(request); + } + + registerPaymentResultListener(handler: (payload: unknown) => void): () => void { + const w = window as unknown as { + onPaymentResult?: (payload: unknown) => void; + AndroidPSP?: { + onPaymentResult?: (payload: unknown) => void; + }; + }; + + const previousGlobal = w.onPaymentResult; + const previousBridge = w.AndroidPSP?.onPaymentResult; + + w.onPaymentResult = handler; + if (w.AndroidPSP) { + w.AndroidPSP.onPaymentResult = handler; + } + + return () => { + w.onPaymentResult = previousGlobal; + if (w.AndroidPSP) { + w.AndroidPSP.onPaymentResult = previousBridge; + } + }; + } + + emitPaymentResultForTest(payload: unknown): void { + const w = window as unknown as { onPaymentResult?: (payload: unknown) => void }; + w.onPaymentResult?.(payload); + } +} diff --git a/src/app/domains/pos/modules/landing/store/main.store.ts b/src/app/domains/pos/modules/landing/store/main.store.ts index bb2b967..00ad5ad 100644 --- a/src/app/domains/pos/modules/landing/store/main.store.ts +++ b/src/app/domains/pos/modules/landing/store/main.store.ts @@ -47,7 +47,7 @@ export const INITIAL_POS_STATE: IPosLandingState = { payments: { cash: 0, set_off: 0, - terminal: 0, + terminals: [], }, submitOrderLoading: false, }; @@ -59,6 +59,7 @@ export class PosLandingStore { private state$ = signal({ ...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); @@ -73,6 +74,9 @@ export class PosLandingStore { } return this.state$().goods?.filter((s) => s.category.id === this.state$().activeGoodCategory); }); + readonly filteredGoods = computed(() => + this.activatedCategoryGoods()?.filter((good) => good.name.includes(this.state$().searchQuery)), + ); readonly customer = computed(() => this.state$().customerDetails); readonly invoiceDate = computed(() => this.state$().invoiceDate); @@ -194,6 +198,8 @@ export class PosLandingStore { this.setState({ searchQuery }); } + filterGoods() {} + resetInOrderGoods() { this.setState({ inOrderGoods: [] }); } @@ -241,7 +247,7 @@ export class PosLandingStore { payments: { cash: 0, set_off: 0, - terminal: 0, + terminals: [], }, orderNote: '', }); diff --git a/src/app/domains/pos/modules/landing/views/root.component.html b/src/app/domains/pos/modules/landing/views/root.component.html index d658399..75d9a9e 100644 --- a/src/app/domains/pos/modules/landing/views/root.component.html +++ b/src/app/domains/pos/modules/landing/views/root.component.html @@ -1,13 +1,47 @@ @if (loading()) { - } @else if (pos()) { -
-
- -
-
- +
+
+
+ +
+
+ + @if (inOrderGoods().length > 0) { + + } + + + +
} diff --git a/src/app/domains/pos/modules/landing/views/root.component.ts b/src/app/domains/pos/modules/landing/views/root.component.ts index 93447b4..d35d543 100644 --- a/src/app/domains/pos/modules/landing/views/root.component.ts +++ b/src/app/domains/pos/modules/landing/views/root.component.ts @@ -1,25 +1,48 @@ // import { CatalogRoleTagComponent } from '@/shared/catalog/roles'; import { PosInfoStore } from '@/domains/pos/store/pos.store'; +import { SharedDialogComponent } from '@/shared/components'; import { PageLoadingComponent } from '@/shared/components/page-loading.component'; -import { Component, computed, inject } from '@angular/core'; +import { PriceMaskDirective } from '@/shared/directives'; +import { Component, computed, inject, signal } from '@angular/core'; +import { ButtonDirective } from 'primeng/button'; import { PosGoodsComponent } from '../components/goods.component'; import { PosOrderSectionComponent } from '../components/order/order-section.component'; +import { PosLandingStore } from '../store/main.store'; @Component({ selector: 'pos-landing', templateUrl: './root.component.html', host: { class: 'grow overflow-hidden' }, - imports: [PosGoodsComponent, PosOrderSectionComponent, PageLoadingComponent], + imports: [ + PosGoodsComponent, + PosOrderSectionComponent, + PageLoadingComponent, + PriceMaskDirective, + SharedDialogComponent, + ButtonDirective, + ], }) export class PosLandingComponent { - private readonly store = inject(PosInfoStore); + private readonly infoStore = inject(PosInfoStore); + private readonly landingStore = inject(PosLandingStore); - readonly loading = computed(() => this.store.loading()); - readonly pos = computed(() => this.store.entity()); - readonly error = computed(() => this.store.error()); + readonly loading = computed(() => this.infoStore.loading()); + readonly pos = computed(() => this.infoStore.entity()); + readonly error = computed(() => this.infoStore.error()); + readonly inOrderGoods = computed(() => this.landingStore.inOrderGoods()); + readonly priceInfo = computed(() => this.landingStore.orderPricingInfo()); + readonly showInvoiceBottomSheet = signal(false); getData() { - this.store.getData().subscribe(); + this.infoStore.getData().subscribe(); + } + + openInvoiceBottomSheet() { + this.showInvoiceBottomSheet.set(true); + } + + closeInvoiceBottomSheet() { + this.showInvoiceBottomSheet.set(false); } } diff --git a/src/app/layout/default/app.layout.component.html b/src/app/layout/default/app.layout.component.html index c890d5b..f938c1c 100644 --- a/src/app/layout/default/app.layout.component.html +++ b/src/app/layout/default/app.layout.component.html @@ -1,7 +1,10 @@
- - - + @if (fullLoading) {
@@ -10,12 +13,12 @@ @if (showMenu()) { } -
+
@if (showBreadcrumb) { } -
+
@if (content) { diff --git a/src/app/layout/default/app.layout.component.ts b/src/app/layout/default/app.layout.component.ts index a2f3561..8951f67 100644 --- a/src/app/layout/default/app.layout.component.ts +++ b/src/app/layout/default/app.layout.component.ts @@ -47,6 +47,9 @@ export class AppLayout { private authService = inject(AuthService); topBarMoreAction?: Maybe> = null; + topBarStartAction?: Maybe> = null; + topBarCenterAction?: Maybe> = null; + topBarEndAction?: Maybe> = null; constructor( public layoutService: LayoutService, @@ -137,6 +140,9 @@ export class AppLayout { get isFixedContentSize(): boolean { return this.layoutService.isFixedContentSize() || false; } + get isFullPage(): boolean { + return this.layoutService.isFullPage() || false; + } showMenu = computed(() => { return this.layoutService.menuItems().length > 0; @@ -144,6 +150,9 @@ export class AppLayout { ngOnInit() { this.layoutService.headerSlot$.subscribe((tpl) => (this.topBarMoreAction = tpl)); + this.layoutService.topbarStartSlot$.subscribe((tpl) => (this.topBarStartAction = tpl)); + this.layoutService.topbarCenterSlot$.subscribe((tpl) => (this.topBarCenterAction = tpl)); + this.layoutService.topbarEndSlot$.subscribe((tpl) => (this.topBarEndAction = tpl)); if (window.location.pathname === '/') { if (!this.authService.currentAccount()) { diff --git a/src/app/layout/default/app.topbar.component.html b/src/app/layout/default/app.topbar.component.html index 0dcf4e0..bb8166e 100644 --- a/src/app/layout/default/app.topbar.component.html +++ b/src/app/layout/default/app.topbar.component.html @@ -1,43 +1,44 @@ -
-
- @if (showMenu) { - - } - -
- -
- -
- - -
+ } +
+ + {{ panelTitle() }} +
+ } + + + @if (centerTemplate) { + + } + + + @if (endTemplate) { + + } @else { +
+ + + + -
- - -
-
-
+
+ + +
+
+ } + + diff --git a/src/app/layout/default/app.topbar.component.ts b/src/app/layout/default/app.topbar.component.ts index 61f7bd3..68524cb 100644 --- a/src/app/layout/default/app.topbar.component.ts +++ b/src/app/layout/default/app.topbar.component.ts @@ -1,22 +1,26 @@ import { AuthService } from '@/core/services/auth.service'; import { CommonModule } from '@angular/common'; -import { Component, computed, inject, Input } from '@angular/core'; +import { Component, computed, inject, Input, TemplateRef } from '@angular/core'; import { RouterModule } from '@angular/router'; import { MenuItem } from 'primeng/api'; -import { Button } from 'primeng/button'; +import { Button, ButtonIcon } from 'primeng/button'; import { MenuModule } from 'primeng/menu'; import { StyleClassModule } from 'primeng/styleclass'; +import { Toolbar } from 'primeng/toolbar'; import images from 'src/assets/images'; import { LayoutService } from '../service/layout.service'; @Component({ selector: 'app-topbar', standalone: true, - imports: [RouterModule, CommonModule, StyleClassModule, MenuModule, Button], + imports: [RouterModule, CommonModule, StyleClassModule, MenuModule, Button, Toolbar, ButtonIcon], templateUrl: './app.topbar.component.html', }) export class AppTopbar { @Input() showMenu: boolean = false; + @Input() startTemplate?: TemplateRef | null = null; + @Input() centerTemplate?: TemplateRef | null = null; + @Input() endTemplate?: TemplateRef | null = null; constructor(public layoutService: LayoutService) {} private authService: AuthService = inject(AuthService); diff --git a/src/app/layout/service/layout.service.ts b/src/app/layout/service/layout.service.ts index 6beb3f0..1c0cc5d 100644 --- a/src/app/layout/service/layout.service.ts +++ b/src/app/layout/service/layout.service.ts @@ -21,6 +21,7 @@ interface LayoutState { staticMenuMobileActive?: boolean; menuHoverActive?: boolean; isFixedContentSize?: boolean; + isFullPage?: boolean; } interface MenuChangeEvent { @@ -47,6 +48,7 @@ export class LayoutService { staticMenuMobileActive: false, menuHoverActive: false, isFixedContentSize: true, + isFullPage: false, }; layoutConfig = signal(this._config); @@ -90,6 +92,7 @@ export class LayoutService { isOverlay = computed(() => this.layoutConfig().menuMode === 'overlay'); isFixedContentSize = computed(() => this.layoutState().isFixedContentSize); + isFullPage = computed(() => this.layoutState().isFullPage); transitionComplete = signal(false); @@ -144,6 +147,13 @@ export class LayoutService { })); } + changeIsFullPage(status: boolean) { + this.layoutState.update((prev) => ({ + ...prev, + isFullPage: status, + })); + } + toggleDarkMode(config?: layoutConfig): void { const _config = config || this.layoutConfig(); localStorage.setItem('isDarkTheme', JSON.stringify(_config.darkTheme)); @@ -221,8 +231,28 @@ export class LayoutService { private headerSlot = new BehaviorSubject | null>(null); headerSlot$ = this.headerSlot.asObservable(); + private topbarStartSlot = new BehaviorSubject | null>(null); + topbarStartSlot$ = this.topbarStartSlot.asObservable(); + private topbarCenterSlot = new BehaviorSubject | null>(null); + topbarCenterSlot$ = this.topbarCenterSlot.asObservable(); + private topbarEndSlot = new BehaviorSubject | null>(null); + topbarEndSlot$ = this.topbarEndSlot.asObservable(); setHeaderSlot(tpl: TemplateRef | null) { + // Backward-compatible alias for topbar end slot. + this.setTopbarEndSlot(tpl); this.headerSlot.next(tpl); } + + setTopbarStartSlot(tpl: TemplateRef | null) { + this.topbarStartSlot.next(tpl); + } + + setTopbarCenterSlot(tpl: TemplateRef | null) { + this.topbarCenterSlot.next(tpl); + } + + setTopbarEndSlot(tpl: TemplateRef | null) { + this.topbarEndSlot.next(tpl); + } } diff --git a/src/app/modules/auth/pages/auth.component.ts b/src/app/modules/auth/pages/auth.component.ts index daf9e4f..cb308db 100644 --- a/src/app/modules/auth/pages/auth.component.ts +++ b/src/app/modules/auth/pages/auth.component.ts @@ -1,5 +1,7 @@ import { IAuthResponse, Maybe, TRoles } from '@/core'; import { ToastService } from '@/core/services/toast.service'; +import { PosPaymentBridgeAbstract } from '@/domains/pos/modules/landing/services/payment-bridge.abstract'; +import { PosPaymentBridgeService } from '@/domains/pos/modules/landing/services/payment-bridge.service'; import { Component, EventEmitter, OnDestroy, OnInit, inject, Input, Output, signal } from '@angular/core'; import { Router } from '@angular/router'; import { ButtonDirective } from 'primeng/button'; @@ -12,6 +14,7 @@ type TSteps = 'login' | 'otp' | 'modifyLoginInfo' | 'signup'; selector: 'app-auth', templateUrl: './auth.component.html', imports: [LoginComponent, ButtonDirective], + providers: [{ provide: PosPaymentBridgeAbstract, useExisting: PosPaymentBridgeService }], }) export class AuthComponent implements OnInit, OnDestroy { @Input() redirectUrl!: string; @@ -23,6 +26,7 @@ export class AuthComponent implements OnInit, OnDestroy { private readonly toastService = inject(ToastService); private readonly router = inject(Router); + private readonly paymentBridge = inject(PosPaymentBridgeAbstract); readonly logo = images.logo; readonly authVector = images.login; @@ -92,33 +96,15 @@ export class AuthComponent implements OnInit, OnDestroy { }; ngOnInit() { - this.listenAndroidPaymentResultCallback(); + this.restorePaymentCallback = this.paymentBridge.registerPaymentResultListener( + this.injectedPaymentResultHandler, + ); } ngOnDestroy() { this.restorePaymentCallback?.(); } - private listenAndroidPaymentResultCallback() { - const w = window as any; - const previousGlobal = w.onPaymentResult; - // Android app can call this function directly via evaluateJavascript. - w.onPaymentResult = this.injectedPaymentResultHandler; - - const bridge = w.AndroidPSP; - const previousBridge = bridge?.onPaymentResult; - if (bridge) { - bridge.onPaymentResult = this.injectedPaymentResultHandler; - } - - this.restorePaymentCallback = () => { - w.onPaymentResult = previousGlobal; - if (bridge) { - bridge.onPaymentResult = previousBridge; - } - }; - } - private handlePaymentResult(result: unknown) { const value = typeof result === 'string' ? result : JSON.stringify(result, null, 2); this.text.set(value); @@ -145,6 +131,6 @@ export class AuthComponent implements OnInit, OnDestroy { } mockPaymentResult() { - this.injectedPaymentResultHandler(`test-result-${Date.now()}`); + this.paymentBridge.emitPaymentResultForTest(`test-result-${Date.now()}`); } } diff --git a/src/app/shared/abstractClasses/abstract-form.ts b/src/app/shared/abstractClasses/abstract-form.ts index f1ba607..7f22789 100644 --- a/src/app/shared/abstractClasses/abstract-form.ts +++ b/src/app/shared/abstractClasses/abstract-form.ts @@ -49,6 +49,8 @@ export abstract class AbstractForm< submitLoading = signal(false); submit() { + console.log('first'); + this.form.markAllAsTouched(); if (this.form.valid) { diff --git a/src/app/shared/components/amountPercentageInput/amount-percentage-input.component.ts b/src/app/shared/components/amountPercentageInput/amount-percentage-input.component.ts index 2559717..b0f0add 100644 --- a/src/app/shared/components/amountPercentageInput/amount-percentage-input.component.ts +++ b/src/app/shared/components/amountPercentageInput/amount-percentage-input.component.ts @@ -166,9 +166,9 @@ export class AmountPercentageInputComponent { private prepareLabel(isPercentageType: boolean) { if (isPercentageType) { - return `${this.label} (${formatWithCurrency(this.amountControl.value || 0)})`; + return `${this.label} (معادل ${formatWithCurrency(this.amountControl.value || 0)})`; } - return `${this.label} (${this.percentageControl.value || 0} درصد)`; + return `${this.label} (معادل ${this.percentageControl.value || 0} درصد)`; } ngOnInit() { diff --git a/src/app/shared/components/dialog/dialog.component.html b/src/app/shared/components/dialog/dialog.component.html new file mode 100644 index 0000000..31f3e6d --- /dev/null +++ b/src/app/shared/components/dialog/dialog.component.html @@ -0,0 +1,33 @@ + + + + +@if (mobileDrawer && isMobile) { + + + +} @else { + + + +} diff --git a/src/app/shared/components/dialog/dialog.component.ts b/src/app/shared/components/dialog/dialog.component.ts index fb13734..b680768 100644 --- a/src/app/shared/components/dialog/dialog.component.ts +++ b/src/app/shared/components/dialog/dialog.component.ts @@ -1,25 +1,25 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core'; import { Dialog } from 'primeng/dialog'; +import { Drawer } from 'primeng/drawer'; @Component({ selector: 'shared-dialog', - template: ` - - - - `, - imports: [Dialog], + templateUrl: './dialog.component.html', + imports: [Dialog, Drawer, NgTemplateOutlet], + styles: [ + ` + :host ::ng-deep .shared-mobile-drawer.p-drawer-bottom { + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + :host ::ng-deep .shared-mobile-drawer .p-drawer-content { + padding-bottom: calc(1rem + env(safe-area-inset-bottom)); + } + `, + ], }) -export class SharedDialogComponent { +export class SharedDialogComponent implements OnInit { @Input() header = ''; @Input() visible = false; @Output() visibleChange = new EventEmitter(); @@ -29,6 +29,29 @@ export class SharedDialogComponent { @Input() draggable = false; @Input() style: Record | undefined; @Input() breakpoints: Record | undefined; + @Input() mobileBreakpoint = 768; + @Input() mobileDrawer = true; + @Input() mobileDrawerHeight = '90svh'; @Output() onHide = new EventEmitter(); + + isMobile = false; + + ngOnInit() { + this.updateViewportMode(); + } + + @HostListener('window:resize') + onResize() { + this.updateViewportMode(); + } + + onVisibilityChange(nextValue: boolean) { + this.visible = nextValue; + this.visibleChange.emit(nextValue); + } + + private updateViewportMode() { + this.isMobile = typeof window !== 'undefined' && window.innerWidth <= this.mobileBreakpoint; + } } diff --git a/src/app/shared/components/formFooterActions/form-footer-actions.component.html b/src/app/shared/components/formFooterActions/form-footer-actions.component.html index e2a8be9..7fa083c 100644 --- a/src/app/shared/components/formFooterActions/form-footer-actions.component.html +++ b/src/app/shared/components/formFooterActions/form-footer-actions.component.html @@ -1,4 +1,4 @@
- +
diff --git a/src/app/shared/components/formFooterActions/form-footer-actions.component.ts b/src/app/shared/components/formFooterActions/form-footer-actions.component.ts index 597f58d..8e845c0 100644 --- a/src/app/shared/components/formFooterActions/form-footer-actions.component.ts +++ b/src/app/shared/components/formFooterActions/form-footer-actions.component.ts @@ -10,6 +10,7 @@ export class FormFooterActionsComponent { @Input() submitLabel = 'تایید'; @Input() cancelLabel = 'لغو'; @Input() loading = false; + @Input() disabled = false; @Output() onSubmit = new EventEmitter(); @Output() onCancel = new EventEmitter(); diff --git a/src/app/shared/components/input/input.component.html b/src/app/shared/components/input/input.component.html index 66d3736..23d1f6b 100644 --- a/src/app/shared/components/input/input.component.html +++ b/src/app/shared/components/input/input.component.html @@ -1,6 +1,20 @@ + @if (labelSuffix) { + +
+ + @if (labelTextView) { + + } @else { + {{ label }} + } + + +
+
+ } @if (type === "switch") { - + } @else { @@ -13,6 +27,7 @@ [attr.placeholder]="preparedPlaceholder" [attr.type]="htmlType" [attr.autocomplete]="autocomplete" + [attr.disabled]="disabled" [class]="` w-full ${size === 'large' ? 'p-inputtext-lg' : size === 'small' ? 'p-inputtext-sm' : ''}`" [required]="isRequired" [invalid]="control.invalid && (control.touched || control.dirty)" @@ -30,6 +45,7 @@ [attr.inputmode]="inputMode" [attr.maxlength]="preparedMaxLength || null" [attr.type]="htmlType" + [attr.disabled]="disabled" [attr.autocomplete]="autocomplete" [classList]=" [ diff --git a/src/app/shared/components/input/input.component.ts b/src/app/shared/components/input/input.component.ts index 74896b2..a3b3a71 100644 --- a/src/app/shared/components/input/input.component.ts +++ b/src/app/shared/components/input/input.component.ts @@ -1,5 +1,6 @@ import { Maybe } from '@/core'; import { ToastService } from '@/core/services/toast.service'; +import { UikitLabelComponent } from '@/uikit'; import { UikitFieldComponent } from '@/uikit/uikit-field.component'; import { CommonModule } from '@angular/common'; import { @@ -35,6 +36,7 @@ import { ToggleSwitch } from 'primeng/toggleswitch'; InputNumberModule, KeyFilterModule, InputMaskModule, + UikitLabelComponent, ], templateUrl: './input.component.html', }) @@ -69,6 +71,8 @@ export class InputComponent { @Output() blur = new EventEmitter(); @ContentChild('suffixTemp', { static: true }) suffixTemp!: TemplateRef | null; + @ContentChild('labelSuffix', { static: false }) labelSuffix!: TemplateRef | null; + @ContentChild('labelTextView', { static: false }) labelTextView!: TemplateRef | null; private readonly toastService = inject(ToastService); @@ -202,14 +206,14 @@ export class InputComponent { const minValidator = (this.min || this.min === 0) && parseFloat(value) < this.min!; const maxValidator = (this.max || this.max === 0) && parseFloat(value) > this.max; if (minValidator || maxValidator) { - if (minValidator) { - this.toastService.warn({ - text: `مقدار ${this.label} نمی‌تواند بیشتر از ${this.min} باشد.`, - }); - } if (maxValidator) { this.toastService.warn({ - text: `مقدار ${this.label} نمی‌تواند کمتر از ${this.max} باشد.`, + text: `مقدار ${this.label} نمی‌تواند بیشتر از ${this.max} باشد.`, + }); + } + if (minValidator) { + this.toastService.warn({ + text: `مقدار ${this.label} نمی‌تواند کمتر از ${this.min} باشد.`, }); } @@ -233,8 +237,12 @@ export class InputComponent { } if (replacedTargetValue !== null) { + console.log(replacedTargetValue); + // @ts-ignore - this.$event.value = replacedTargetValue; + $event.value = replacedTargetValue; + // @ts-ignore + $event.target.value = replacedTargetValue; } } } diff --git a/src/app/shared/components/search/search-input.component.html b/src/app/shared/components/search/search-input.component.html index 11ca93b..c7f412c 100644 --- a/src/app/shared/components/search/search-input.component.html +++ b/src/app/shared/components/search/search-input.component.html @@ -1 +1 @@ - + diff --git a/src/app/uikit/uikit-field.component.ts b/src/app/uikit/uikit-field.component.ts index 671d5be..9145fbc 100644 --- a/src/app/uikit/uikit-field.component.ts +++ b/src/app/uikit/uikit-field.component.ts @@ -19,6 +19,7 @@ export class UikitFieldComponent { @Input() pSize: PSize = 'normal'; @Input() className?: string; @Input() invalid?: boolean = false; + @Input() disabled?: boolean = false; // accept a FormControl or AbstractControl to display errors for @Input() control?: AbstractControl | null; diff --git a/src/assets/customize.scss b/src/assets/customize.scss new file mode 100644 index 0000000..f8b932b --- /dev/null +++ b/src/assets/customize.scss @@ -0,0 +1,3 @@ +:root { + --p-drawer-header-padding: 0.5rem 0.875rem !important; +} diff --git a/src/assets/layout/_core.scss b/src/assets/layout/_core.scss index 471b285..405db59 100644 --- a/src/assets/layout/_core.scss +++ b/src/assets/layout/_core.scss @@ -26,6 +26,6 @@ html { @media (max-width: 640px) { html { - font-size: 13px; + font-size: 14px; } } diff --git a/src/assets/layout/_main.scss b/src/assets/layout/_main.scss index bdb4720..6f20ca1 100644 --- a/src/assets/layout/_main.scss +++ b/src/assets/layout/_main.scss @@ -11,9 +11,13 @@ transform: translateX(0) !important; padding-inline-start: 2rem !important; } + &.isFullPage { + padding-inline-start: 0 !important; + padding: 0 !important; + } } .layout-main { flex: 1 1 auto; - overflow: auto; + // overflow: auto; } diff --git a/src/assets/layout/_responsive.scss b/src/assets/layout/_responsive.scss index f930760..288f414 100644 --- a/src/assets/layout/_responsive.scss +++ b/src/assets/layout/_responsive.scss @@ -1,110 +1,122 @@ @media screen and (min-width: 1960px) { - .layout-main, - .landing-wrapper { - width: 1504px; - margin-right: auto !important; - margin-left: auto !important; - } + .layout-main, + .landing-wrapper { + width: 1504px; + margin-right: auto !important; + margin-left: auto !important; + } } -@media (min-width: 992px) { - .layout-wrapper { - &.layout-overlay { - .layout-main-container { - margin-inline-start: 0; - padding-inline-start: 2rem; - } - - .layout-sidebar { - transform: translateX(100%); - right: 0; - top: 0; - height: 100vh; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-inline-end: 1px solid var(--surface-border); - transition: - transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99), - right 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99); - box-shadow: - 0px 3px 5px rgba(0, 0, 0, 0.02), - 0px 0px 2px rgba(0, 0, 0, 0.05), - 0px 1px 4px rgba(0, 0, 0, 0.08); - } - - &.layout-overlay-active { - .layout-sidebar { - transform: translateX(0); - } - } - } - - &.layout-static { - .layout-main-container { - margin-inline-start: 22rem; - } - - &.layout-static-inactive { - .layout-sidebar { - transform: translateX(100%); - right: 0; - } - - .layout-main-container { - margin-inline-start: 0; - padding-inline-start: 2rem; - } - } - } - - .layout-mask { - display: none; - } +@media (max-width: 620px) { + .layout-main-container { + padding: 1rem 0.5rem 1rem 1rem; + &.hideMenu { + padding-inline-start: 1rem !important; } + &.isFullPage { + padding-inline-start: 0 !important; + padding: 0 !important; + } + } +} +@media (min-width: 992px) { + .layout-wrapper { + &.layout-overlay { + .layout-main-container { + margin-inline-start: 0; + padding-inline-start: 2rem; + } + + .layout-sidebar { + transform: translateX(100%); + right: 0; + top: 0; + height: 100vh; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-inline-end: 1px solid var(--surface-border); + transition: + transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99), + right 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99); + box-shadow: + 0px 3px 5px rgba(0, 0, 0, 0.02), + 0px 0px 2px rgba(0, 0, 0, 0.05), + 0px 1px 4px rgba(0, 0, 0, 0.08); + } + + &.layout-overlay-active { + .layout-sidebar { + transform: translateX(0); + } + } + } + + &.layout-static { + .layout-main-container { + margin-inline-start: 22rem; + } + + &.layout-static-inactive { + .layout-sidebar { + transform: translateX(100%); + right: 0; + } + + .layout-main-container { + margin-inline-start: 0; + padding-inline-start: 2rem; + } + } + } + + .layout-mask { + display: none; + } + } } @media (max-width: 991px) { - .blocked-scroll { - overflow: hidden; + .blocked-scroll { + overflow: hidden; + } + + .layout-wrapper { + .layout-main-container { + margin-inline-start: 0; + padding-inline-start: 2rem; } - .layout-wrapper { - .layout-main-container { - margin-inline-start: 0; - padding-inline-start: 2rem; - } - - .layout-sidebar { - transform: translateX(100%); - right: 0; - top: 0; - height: 100vh; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - transition: - transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99), - left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99); - } - - .layout-mask { - display: none; - position: fixed; - top: 0; - right: 0; - z-index: 998; - width: 100%; - height: 100%; - background-color: var(--maskbg); - } - - &.layout-mobile-active { - .layout-sidebar { - transform: translateX(0); - } - - .layout-mask { - display: block; - } - } + .layout-sidebar { + transform: translateX(100%); + right: 0; + top: 0; + height: 100vh; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + transition: + transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99), + left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99); } + + .layout-mask { + display: none; + position: fixed; + top: 0; + right: 0; + z-index: 998; + width: 100%; + height: 100%; + background-color: var(--maskbg); + } + + &.layout-mobile-active { + .layout-sidebar { + transform: translateX(0); + } + + .layout-mask { + display: block; + } + } + } } diff --git a/src/assets/rtlSupport.scss b/src/assets/rtlSupport.scss index fba933e..2c3fcce 100644 --- a/src/assets/rtlSupport.scss +++ b/src/assets/rtlSupport.scss @@ -66,3 +66,7 @@ input.p-inputtext { .p-avatar-image { overflow: hidden !important; } + +.p-drawer { + border-radius: 1rem 1rem 0 0 !important; +} diff --git a/src/assets/styles.scss b/src/assets/styles.scss index d674482..24fcb6e 100644 --- a/src/assets/styles.scss +++ b/src/assets/styles.scss @@ -7,8 +7,10 @@ @use "primeicons/primeicons.css"; @use "./demo/demo.scss"; @use "./rtlSupport.scss"; +@use "./customize.scss"; -form { +form, +.form-group { display: flex; flex-direction: column; gap: 1rem; diff --git a/src/tenants/default/app.routes.ts b/src/tenants/default/app.routes.ts index 4965e3d..458d540 100644 --- a/src/tenants/default/app.routes.ts +++ b/src/tenants/default/app.routes.ts @@ -19,13 +19,13 @@ export const appRoutes: Routes = [ CONSUMER_ROUTES, PROVIDER_ROUTES, PARTNER_ROUTES, + POS_ROUTES, { path: 'ng', component: Dashboard }, { path: 'uikit', loadChildren: () => import('@/pages/uikit/uikit.routes') }, { path: 'documentation', component: Documentation }, { path: 'pages', loadChildren: () => import('@/pages/pages.routes') }, ], }, - POS_ROUTES, { path: 'auth', component: AuthComponent,