feat: enhance inner pages header with back button functionality and emit event on back click

fix: update total price info handling in correction form and ensure accurate calculations

refactor: streamline sale invoice single view component by extracting info card into a separate component

style: adjust button sizes in payment forms for better UI consistency

feat: implement season picker navigation controls to prevent out-of-bounds selection

fix: improve price formatting utility to handle undefined values gracefully

chore: update environment configuration for API base URL

feat: add navigation service to manage routing history and back navigation
This commit is contained in:
2026-06-11 16:13:48 +03:30
parent 88f45eee38
commit b4cd4c05f2
28 changed files with 526 additions and 310 deletions
+5 -1
View File
@@ -1,3 +1,4 @@
import { NavigationService } from '@/core/services/navigation.service';
import { PwaInstallService } from '@/core/services/pwa-install.service';
import { ConfirmationDialogComponent } from '@/shared/components/confirmationDialog/confirmation-dialog.component';
import { Component, HostListener } from '@angular/core';
@@ -18,7 +19,10 @@ import { brandingConfig } from './app/branding/branding.config';
export class AppComponent {
toastPosition: 'top-center' | 'bottom-right' = 'bottom-right';
constructor(private readonly pwaInstallService: PwaInstallService) {
constructor(
private readonly pwaInstallService: PwaInstallService,
private readonly navigationService: NavigationService
) {
this.updateToastPosition();
if (brandingConfig.enableInstallPrompt) {
this.pwaInstallService.init();
@@ -135,6 +135,8 @@ export class NativeBridgeService {
this.toastService.info({ text: 'در حال چاپ ...' });
// @ts-ignore
window.NativeBridge.print(JSON.stringify(payload));
console.log('payload', payload);
return { success: true };
} catch (error) {
this.toastService.warn({ text: 'متاسفانه ارتباط با چاپگر برقرار نیست.' });
@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class NavigationService {
private readonly CURRENT_URL_KEY = 'currentUrl';
private readonly PREVIOUS_URL_KEY = 'previousUrl';
currentUrl: string | null;
previousUrl: string | null;
constructor(private router: Router) {
this.currentUrl = sessionStorage.getItem(this.CURRENT_URL_KEY);
this.previousUrl = sessionStorage.getItem(this.PREVIOUS_URL_KEY);
this.router.events
.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd))
.subscribe((event) => {
const newUrl = event.urlAfterRedirects;
// Ignore duplicate events
if (newUrl === this.currentUrl) {
return;
}
this.previousUrl = this.currentUrl;
this.currentUrl = newUrl;
if (this.previousUrl) {
sessionStorage.setItem(this.PREVIOUS_URL_KEY, this.previousUrl);
}
sessionStorage.setItem(this.CURRENT_URL_KEY, this.currentUrl);
});
}
canGoBack(): boolean {
return !!this.previousUrl;
}
back(fallbackUrl = '/'): void {
if (this.canGoBack()) {
window.history.back();
} else {
this.router.navigateByUrl(fallbackUrl);
}
}
}
@@ -11,22 +11,17 @@
<app-checkbox
[control]="form.controls.customer_national_id"
name="customer_national_id"
label="نمایش کد ملی خریدار"
/>
label="نمایش کد ملی خریدار" />
<app-checkbox
[control]="form.controls.customer_postal_code"
name="customer_postal_code"
label="نمایش کد پستی خریدار"
/>
label="نمایش کد پستی خریدار" />
<app-checkbox
[control]="form.controls.customer_economic_code"
name="customer_economic_code"
label="نمایش شناسه‌ملی / اقتصادی خریدار"
/>
label="نمایش شناسه‌ملی / اقتصادی خریدار" />
<app-checkbox [control]="form.controls.payment_type" name="payment_type" label="نمایش نوع پرداخت" />
<app-checkbox [control]="form.controls.show_payment_info" name="show_payment_info" label="نمایش جزییات پرداخت" />
<app-checkbox [control]="form.controls.show_items" name="show_items" label="نمایش کالاهای خریداری شده" />
<app-form-footer-actions (onCancel)="close()" />
</form>
}
@@ -1,6 +1,5 @@
import { AbstractForm } from '@/shared/abstractClasses';
import { AppCheckboxComponent } from '@/shared/components/checkbox/checkbox.component';
import { FormFooterActionsComponent } from '@/shared/components/formFooterActions/form-footer-actions.component';
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { IPosConfigPrintRequestPayload, IPosConfigPrintResponse } from './models';
@@ -9,7 +8,7 @@ import { PosConfigPrintService } from './services/main.service';
@Component({
selector: 'pos-config-print-form',
templateUrl: 'form.component.html',
imports: [ReactiveFormsModule, AppCheckboxComponent, FormFooterActionsComponent],
imports: [ReactiveFormsModule, AppCheckboxComponent],
})
export class PosConfigPrintFormComponent extends AbstractForm<
IPosConfigPrintRequestPayload,
@@ -37,6 +36,10 @@ export class PosConfigPrintFormComponent extends AbstractForm<
show_items: [false],
});
form.valueChanges.subscribe(() => {
this.submitForm();
});
return form;
};
@@ -52,7 +55,7 @@ export class PosConfigPrintFormComponent extends AbstractForm<
override submitForm() {
const formValue = this.form.value as IPosConfigPrintRequestPayload;
this.toastService.success({ text: 'تغییرات با موفقیت اعمال شد.' });
// this.toastService.success({ text: 'تغییرات با موفقیت اعمال شد.' });
return this.service.submit(formValue);
}
}
@@ -15,17 +15,15 @@ export interface IPosConfigPrintRequestPayload {
payment_type: boolean;
show_payment_info: boolean;
show_items: boolean;
items: {
name: boolean;
sku: boolean;
quantity: boolean;
base_amount: boolean;
tax: boolean;
discount: boolean;
karat: boolean;
profit: boolean;
commission: boolean;
wage: boolean;
total_amount: boolean;
};
item_name: boolean;
item_sku: boolean;
item_quantity: boolean;
item_base_amount: boolean;
item_tax: boolean;
item_discount: boolean;
item_karat: boolean;
item_profit: boolean;
item_commission: boolean;
item_wage: boolean;
item_total_amount: boolean;
}
@@ -7,7 +7,12 @@ export class PosConfigPrintService {
get(): IPosConfigPrintResponse {
const data = window.localStorage.getItem(LOCAL_STORAGE_KEYS.POS_CONFIG_PRINT);
if (data) {
return JSON.parse(data) as IPosConfigPrintResponse;
const dataObject = JSON.parse(data) as Record<keyof IPosConfigPrintResponse, string>;
return Object.keys(dataObject).reduce((prev, curr) => {
const key = curr as keyof IPosConfigPrintResponse;
prev[key] = dataObject[key] === 'true';
return prev;
}, {} as IPosConfigPrintResponse);
}
const defaultData = {
business_name: true,
@@ -23,25 +28,37 @@ export class PosConfigPrintService {
customer_economic_code: true,
payment_type: true,
show_payment_info: true,
show_items: false,
items: {
name: false,
sku: false,
quantity: false,
base_amount: false,
tax: false,
discount: false,
karat: false,
profit: false,
commission: false,
wage: false,
total_amount: false,
},
show_items: true,
item_name: false,
item_sku: false,
item_quantity: false,
item_base_amount: false,
item_tax: false,
item_discount: false,
item_karat: false,
item_profit: false,
item_commission: false,
item_wage: false,
item_total_amount: false,
};
this.submit(defaultData);
return defaultData;
}
getVisibleItems() {
const items = this.get();
return Object.keys(items).reduce((result, key) => {
const itemKey = key as keyof IPosConfigPrintRequestPayload;
if (items[itemKey]) {
result[itemKey] = items[itemKey];
}
return result;
}, {} as Partial<IPosConfigPrintRequestPayload>);
}
submit(data: IPosConfigPrintRequestPayload) {
window.localStorage.setItem(LOCAL_STORAGE_KEYS.POS_CONFIG_PRINT, JSON.stringify(data));
}
@@ -1,3 +1,9 @@
<div class="min-h-dvh p-4">
<shared-sale-invoice-single-view [loading]="loading()" [invoice]="invoice()" />
<p-card class="mb-4">
<div class="flex items-center justify-between">
<span class="text-lg font-bold">صورت‌حساب شماره {{ invoice()?.invoice_number }}</span>
<button pButton type="button" icon="pi pi-share-alt" outlined (onClick)="share()"></button>
</div>
</p-card>
<shared-sale-invoice-single-info-card [loading]="loading()" [invoice]="invoice()" />
</div>
@@ -1,13 +1,15 @@
import { SharedSaleInvoiceSingleViewComponent } from '@/shared/components/invoices/sale-invoice-single-view.component';
import { SaleInvoiceSingleInfoCardComponent } from '@/shared/components/invoices/sale-invoice-single-info-card.component';
import pageParamsUtils from '@/utils/page-params.utils';
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { PublicSaleInvoiceStore } from '../store/main.store';
@Component({
selector: 'public-saleInvoice',
templateUrl: './single.component.html',
imports: [SharedSaleInvoiceSingleViewComponent],
imports: [SaleInvoiceSingleInfoCardComponent, Card, ButtonDirective],
})
export class PublicSaleInvoiceComponent {
private readonly route = inject(ActivatedRoute);
@@ -22,4 +24,14 @@ export class PublicSaleInvoiceComponent {
ngOnInit() {
this.store.getData(this.invoiceId());
}
async share() {
if (navigator.share) {
await navigator.share({
title: window.document.title,
text: 'برای مشاهده صورت‌حساب اینجا کلیک کنید.',
url: window.location.href,
});
}
}
}
@@ -20,7 +20,9 @@
</header>
}
<div class="light-bottomsheet-body p-4">
<ng-content></ng-content>
@if (contentRendered) {
<ng-content></ng-content>
}
</div>
</div>
</section>
@@ -105,10 +105,12 @@ export class SharedLightBottomsheetComponent implements OnInit, OnDestroy, OnCha
@Input() style: Record<string, string> | undefined;
@Input() mobileDrawerHeight = '90vh';
@Input() transitionOptions = '130ms cubic-bezier(0.2, 0, 0, 1)';
@Input() closeUnmountDelay = 150;
@Input() closeUnmountDelay = 20;
@Output() onHide = new EventEmitter<any>();
rendered = false;
contentRendered = false;
private isClosing = false;
private closeTimer: ReturnType<typeof setTimeout> | null = null;
private originalParent: Node | null = null;
@@ -127,14 +129,16 @@ export class SharedLightBottomsheetComponent implements OnInit, OnDestroy, OnCha
this.originalParent = host.parentNode;
this.renderer.appendChild(this.document.body, host);
this.rendered = this.visible;
this.contentRendered = this.visible;
this.syncZIndex();
this.toggleBodyScrollLock(this.visible);
}
ngOnChanges(changes: SimpleChanges) {
if (changes['visible'] && this.visible) {
if (changes['visible'] && this.visible && !this.isClosing) {
this.clearCloseTimer();
this.rendered = true;
this.contentRendered = true;
this.syncZIndex();
}
if (changes['visible']) {
@@ -159,18 +163,20 @@ export class SharedLightBottomsheetComponent implements OnInit, OnDestroy, OnCha
}
onVisibilityChange(nextValue: boolean) {
this.visible = nextValue;
this.visibleChange.emit(nextValue);
this.toggleBodyScrollLock(nextValue);
if (nextValue) {
this.visible = nextValue;
this.isClosing = false;
this.clearCloseTimer();
this.rendered = true;
this.contentRendered = true;
this.syncZIndex();
}
if (!nextValue) {
this.isClosing = true;
this.scheduleUnmount();
this.onHide.emit();
}
this.visibleChange.emit(nextValue);
this.toggleBodyScrollLock(nextValue);
}
onMaskClick() {
@@ -217,12 +223,14 @@ export class SharedLightBottomsheetComponent implements OnInit, OnDestroy, OnCha
}
private scheduleUnmount() {
if (!this.visible) {
this.isClosing = false;
this.contentRendered = false;
this.rendered = false;
this.onHide.emit();
}
this.clearCloseTimer();
this.closeTimer = setTimeout(() => {
if (!this.visible) {
this.rendered = false;
}
}, this.closeUnmountDelay);
this.closeTimer = setTimeout(() => {}, this.closeUnmountDelay);
}
private clearCloseTimer() {
@@ -1,10 +1,16 @@
<div class="bg-surface-card flex items-center p-4">
<div class="bg-surface-card flex items-center px-4 py-3">
<div class="grow">
<div class="flex items-center gap-2">
@if (backRoute) {
<p-button icon="pi pi-arrow-right" aria-label="بازگشت" outlined size="small" [routerLink]="backRoute" />
@if (backRoute || onBackClick) {
<p-button
icon="pi pi-arrow-right"
aria-label="بازگشت"
outlined
size="small"
[routerLink]="backRoute"
(click)="onBackClick.emit()" />
}
<h5 class="py-1.5 font-bold">{{ pageTitle }}</h5>
<h5 class="py-1.25 text-lg! font-bold">{{ pageTitle }}</h5>
</div>
</div>
@if (actions) {
@@ -1,6 +1,6 @@
import { Maybe } from '@/core';
import { NgTemplateOutlet } from '@angular/common';
import { Component, ContentChild, Input, TemplateRef } from '@angular/core';
import { Component, ContentChild, EventEmitter, Input, Output, TemplateRef } from '@angular/core';
import { RouterLink, UrlTree } from '@angular/router';
import { Button } from 'primeng/button';
@@ -12,6 +12,7 @@ import { Button } from 'primeng/button';
export class InnerPagesHeaderComponent {
@Input() pageTitle!: string;
@Input() backRoute?: UrlTree | string | any[];
@Output() onBackClick = new EventEmitter<void>();
@ContentChild('actions', { static: true }) actions?: Maybe<TemplateRef<any>>;
constructor() {}
}
@@ -41,7 +41,7 @@
}
</div>
<pos-order-price-info-card [info]="getTotalPriceInfo()" />
<pos-order-price-info-card [info]="totalPriceInfo" />
<app-form-footer-actions />
</form>
@@ -48,6 +48,12 @@ export class SharedCorrectionFormComponent extends AbstractForm<
itemDrafts: Record<string, TCorrectionItemPayload> = {};
mappedInitialItems: TCorrectionItemPayload[] = [];
totalPriceInfo = {
totalBaseAmount: 0,
discountAmount: 0,
taxAmount: 0,
totalAmount: 0,
};
showItemEditSheet = signal(false);
activeItemIndex = signal<number | null>(null);
@@ -57,6 +63,7 @@ export class SharedCorrectionFormComponent extends AbstractForm<
);
this.itemDrafts = {};
this.mappedInitialItems = (this.initialValues || []).map((item) => this.mapToInitialItem(item));
this.recalculateTotalPriceInfo();
this.showItemEditSheet.set(false);
this.activeItemIndex.set(null);
}
@@ -91,9 +98,9 @@ export class SharedCorrectionFormComponent extends AbstractForm<
good_id: item.good_id,
unit_price: Number(item.unit_price || 0),
quantity: Number(item.quantity || 0),
discount_amount: Number(item.discount || 0),
discount_amount: Number(item.discount_amount || 0),
total_amount: Number(item.total_amount || 0),
tax_amount: 0,
tax_amount: Number(item.tax_amount || 0),
base_total_amount: Number(item.unit_price || 0) * Number(item.quantity || 0),
measure_unit: (item.good_snapshot?.measure_unit || {}) as ISummary,
};
@@ -158,9 +165,6 @@ export class SharedCorrectionFormComponent extends AbstractForm<
const previews = (this.initialValues || [])
.map((_, index) => this.getItemPreview(index))
.filter(Boolean);
console.log('previews', previews);
return previews.reduce(
(acc, item: any) => {
acc.totalBaseAmount += Number(item.base_total_amount || 0);
@@ -178,6 +182,10 @@ export class SharedCorrectionFormComponent extends AbstractForm<
);
}
private recalculateTotalPriceInfo() {
this.totalPriceInfo = this.getTotalPriceInfo();
}
saveActiveItemDraft(
goldForm?: SharedGoldPayloadFormComponent,
standardForm?: SharedStandardPayloadFormComponent
@@ -200,6 +208,7 @@ export class SharedCorrectionFormComponent extends AbstractForm<
id: item.id,
pricing_model: item.good_snapshot?.pricing_model || '',
};
this.recalculateTotalPriceInfo();
this.backToList();
}
@@ -40,12 +40,13 @@ export interface IInvoiceItem {
measure_unit_text: string;
sku_code: string;
unit_price: string;
discount: string;
discount_amount: string;
total_amount: string;
notes?: string;
payload: Payload;
good_snapshot: Goodsnapshot;
good: GoodSummary;
tax_amount: string;
}
interface GoodSummary {
name: string;
@@ -132,7 +133,12 @@ interface Pos extends ISummary {
}
interface Complex extends ISummary {
business_activity: ISummary;
business_activity: BusinessActivity;
}
interface BusinessActivity extends ISummary {
economic_code: string;
guild: ISummary;
}
interface ConsumerAccount {
@@ -0,0 +1,93 @@
@if (invoice) {
<p-card>
<div class="flex flex-col gap-4">
<div class="listKeyValue">
<app-key-value label="شماره منحصر به فرد مالیاتی" [value]="invoice.tax_id" />
<app-key-value label="تاریخ صورت‌حساب‌" [value]="invoice.invoice_date" type="date" />
<app-key-value label="تاریخ ایجاد صورت‌حساب‌" [value]="invoice.created_at" type="dateTime" />
<app-key-value label="نوع صورت‌حساب">
<catalog-invoice-type-tag [status]="invoice.type.value" />
</app-key-value>
<app-key-value label="وضعیت صدور به سامانه‌ی مودیان">
<catalog-tax-provider-status-tag [status]="invoice.status.value" [translate]="invoice.status.translate" />
</app-key-value>
<div class="col-span-full">
<app-key-value label="توضیحات" [value]="invoice.notes || '----'" />
</div>
</div>
<p-divider align="center"> اطلاعات مالی</p-divider>
<div class="grid items-center gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
<app-key-value label="مبلغ تخفیف" [value]="invoice.discount_amount" type="price" />
<app-key-value label="مبلغ مالیات" [value]="invoice.tax_amount" type="price" />
<app-key-value label="مجموع قابل پرداخت" [value]="invoice.total_amount" type="price" />
</div>
<p-divider align="center"> اطلاعات پرداخت</p-divider>
<div class="grid items-center gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
<app-key-value label="نحوه‌ی تسویه‌حساب">
<catalog-invoice-settlement-type-tag [type]="invoice.settlement_type.value" />
</app-key-value>
@for (payment of invoice.payments; track $index) {
<app-key-value
[label]="`${payment.payment_method === 'SET_OFF' ? 'تهاتر' : payment.payment_method === 'TERMINAL' ? 'پوز' : 'نقدی/ کارت‌خوان دیگر/ کارت به کارت'}`"
[value]="payment.amount"
type="price" />
}
</div>
<p-divider align="center"> اطلاعات صادر کننده </p-divider>
<div class="listKeyValue">
<app-key-value label="کسب و کار" [value]="invoice.pos!.complex.business_activity.name" />
<app-key-value label="مجموعه" [value]="invoice.pos!.complex.name" />
<app-key-value label="پایانه فروش" [value]="invoice.pos!.name" />
@if (invoice.consumer_account?.account?.username) {
<app-key-value label="ایجاد کننده" [value]="invoice.consumer_account.account.username" />
}
</div>
@if (variant !== 'customer') {
<p-divider align="center"> اطلاعات مشتری </p-divider>
@if (invoice.customer) {
<div class="listKeyValue">
@if (invoice.customer.type === 'INDIVIDUAL') {
<app-key-value label="نوع مشتری" value="حقیقی" />
<app-key-value label="نام" [value]="invoice.customer.individual?.first_name" />
<app-key-value label="نام خانوادگی" [value]="invoice.customer.individual?.last_name" />
<app-key-value label="کد اقتصادی" [value]="invoice.customer.individual?.economic_code" />
<app-key-value label="کد ملی" [value]="invoice.customer.individual?.national_id" />
<app-key-value label="کد پستی" [value]="invoice.customer.individual?.postal_code" />
} @else {
<app-key-value label="نوع مشتری" value="حقوقی" />
<app-key-value label="نام شرکت" [value]="invoice.customer.legal?.company_name" />
<app-key-value label="کد اقتصادی" [value]="invoice.customer.legal?.economic_code" />
<app-key-value label="کد ثبتی" [value]="invoice.customer.legal?.registration_number" />
<app-key-value label="کد پستی" [value]="invoice.customer.legal?.postal_code" />
}
</div>
} @else if (invoice.unknown_customer) {
<div class="listKeyValue">
<app-key-value label="نوع مشتری" value="نوع دوم" />
<app-key-value label="عنوان" [value]="invoice.unknown_customer.name" />
<app-key-value label="کد ثبتی" [value]="invoice.unknown_customer.national_code" />
</div>
} @else {
<p class="text-text-color pt-3 pb-5 text-center">اطلاعات مشتری ثبت نشده است.</p>
}
}
<p-divider align="center"> کالاهای خریداری شده </p-divider>
<app-page-data-list
[columns]="columns"
[items]="invoice.items"
[loading]="loading"
pageTitle=""
[showRefresh]="false">
<ng-template #totalAmount let-item>
@if (!item.discount_amount) {
<span [appPriceMask]="item.total_amount"></span>
}
</ng-template>
</app-page-data-list>
</div>
</p-card>
}
@@ -0,0 +1,104 @@
import { Maybe } from '@/core';
import {
CatalogInvoiceTypeTagComponent,
CatalogTaxProviderStatusTagComponent,
} from '@/shared/catalog';
import { CatalogInvoiceSettlementTypeTagComponent } from '@/shared/catalog/invoiceSettlementType';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, Input } from '@angular/core';
import { Card } from 'primeng/card';
import { Divider } from 'primeng/divider';
import { KeyValueComponent } from '../key-value.component/key-value.component';
import { IColumn, PageDataListComponent } from '../pageDataList/page-data-list.component';
import { ISaleInvoiceFullResponse } from './sale-invoice-full-response.model';
export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos';
@Component({
selector: 'shared-sale-invoice-single-info-card',
templateUrl: './sale-invoice-single-info-card.component.html',
imports: [
Card,
KeyValueComponent,
CatalogInvoiceTypeTagComponent,
CatalogTaxProviderStatusTagComponent,
Divider,
CatalogInvoiceSettlementTypeTagComponent,
PageDataListComponent,
PriceMaskDirective,
],
})
export class SaleInvoiceSingleInfoCardComponent {
@Input({ required: true }) loading!: boolean;
@Input() invoice!: Maybe<ISaleInvoiceFullResponse>;
@Input() variant: TSaleInvoiceSingleViewVariant = 'full';
columns: IColumn[] = [
{
field: 'name',
header: 'عنوان',
type: 'nested',
nestedOption: {
path: 'good_snapshot.name',
},
},
{
field: 'sku_code',
header: 'شناسه کالا',
},
{
field: 'unit_price',
header: 'قیمت واحد',
type: 'price',
},
{
field: 'quantity',
header: 'مقدار',
customDataModel(item) {
return `${item.quantity} ${item.measure_unit_text}`;
},
},
{
field: 'commission',
header: 'کارمزد',
type: 'nested',
nestedOption: {
path: 'payload.commission',
type: 'price',
},
},
{
field: 'wages',
header: 'اجرت',
type: 'nested',
nestedOption: {
path: 'payload.wages',
type: 'price',
},
},
{
field: 'profit',
header: 'سود',
type: 'nested',
nestedOption: {
path: 'payload.profit',
type: 'price',
},
},
{
field: 'discount_amount',
header: 'مبلغ تخفیف',
type: 'price',
},
{
field: 'tax_amount',
header: 'مبلغ مالیات',
type: 'price',
},
{
field: 'total_amount',
header: 'مبلغ قابل پرداخت',
type: 'price',
},
];
}
@@ -3,103 +3,14 @@
} @else if (!invoice) {
<uikit-empty-state> </uikit-empty-state>
} @else {
<app-inner-pages-header [pageTitle]="`صورت‌حساب ${invoice.invoice_number}`" [backRoute]="backRoute">
<app-inner-pages-header [pageTitle]="`صورت‌حساب ${invoice.invoice_number}`" (onBackClick)="backToPrevPage()">
<ng-template #actions>
<p-button (click)="showQrCode()" icon="pi pi-qrcode" outlined size="small" />
<p-button (click)="printInvoice()" icon="pi pi-print" outlined size="small" label="چاپ" />
</ng-template>
</app-inner-pages-header>
<div class="p-4">
<p-card>
<div class="flex flex-col gap-4">
<div class="listKeyValue">
<app-key-value label="شماره منحصر به فرد مالیاتی" [value]="invoice.tax_id" />
<app-key-value label="تاریخ صورت‌حساب‌" [value]="invoice.invoice_date" type="date" />
<app-key-value label="تاریخ ایجاد صورت‌حساب‌" [value]="invoice.created_at" type="dateTime" />
<app-key-value label="نوع صورت‌حساب">
<catalog-invoice-type-tag [status]="invoice.type.value" />
</app-key-value>
<app-key-value label="وضعیت صدور به سامانه‌ی مودیان">
<catalog-tax-provider-status-tag [status]="invoice.status.value" [translate]="invoice.status.translate" />
</app-key-value>
<div class="col-span-full">
<app-key-value label="توضیحات" [value]="invoice.notes || '----'" />
</div>
</div>
<p-divider align="center"> اطلاعات مالی</p-divider>
<div class="grid items-center gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
<app-key-value label="مبلغ تخفیف" [value]="invoice.discount_amount" type="price" />
<app-key-value label="مبلغ مالیات" [value]="invoice.tax_amount" type="price" />
<app-key-value label="مجموع قابل پرداخت" [value]="invoice.total_amount" type="price" />
</div>
<p-divider align="center"> اطلاعات پرداخت</p-divider>
<div class="grid items-center gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
<app-key-value label="نحوه‌ی تسویه‌حساب">
<catalog-invoice-settlement-type-tag [type]="invoice.settlement_type.value" />
</app-key-value>
@for (payment of invoice.payments; track $index) {
<app-key-value
[label]="`${payment.payment_method === 'SET_OFF' ? 'تهاتر' : payment.payment_method === 'TERMINAL' ? 'پوز' : 'نقدی/ کارت‌خوان دیگر/ کارت به کارت'}`"
[value]="payment.amount"
type="price" />
}
</div>
<p-divider align="center"> اطلاعات صادر کننده </p-divider>
<div class="listKeyValue">
<app-key-value label="کسب و کار" [value]="invoice.pos!.complex.business_activity.name" />
<app-key-value label="مجموعه" [value]="invoice.pos!.complex.name" />
<app-key-value label="پایانه فروش" [value]="invoice.pos!.name" />
@if (invoice.consumer_account?.account?.username) {
<app-key-value label="ایجاد کننده" [value]="invoice.consumer_account.account.username" />
}
</div>
@if (variant !== 'customer') {
<p-divider align="center"> اطلاعات مشتری </p-divider>
@if (invoice.customer) {
<div class="listKeyValue">
@if (invoice.customer.type === 'INDIVIDUAL') {
<app-key-value label="نوع مشتری" value="حقیقی" />
<app-key-value label="نام" [value]="invoice.customer.individual?.first_name" />
<app-key-value label="نام خانوادگی" [value]="invoice.customer.individual?.last_name" />
<app-key-value label="کد اقتصادی" [value]="invoice.customer.individual?.economic_code" />
<app-key-value label="کد ملی" [value]="invoice.customer.individual?.national_id" />
<app-key-value label="کد پستی" [value]="invoice.customer.individual?.postal_code" />
} @else {
<app-key-value label="نوع مشتری" value="حقوقی" />
<app-key-value label="نام شرکت" [value]="invoice.customer.legal?.company_name" />
<app-key-value label="کد اقتصادی" [value]="invoice.customer.legal?.economic_code" />
<app-key-value label="کد ثبتی" [value]="invoice.customer.legal?.registration_number" />
<app-key-value label="کد پستی" [value]="invoice.customer.legal?.postal_code" />
}
</div>
} @else if (invoice.unknown_customer) {
<div class="listKeyValue">
<app-key-value label="نوع مشتری" value="نوع دوم" />
<app-key-value label="عنوان" [value]="invoice.unknown_customer.name" />
<app-key-value label="کد ثبتی" [value]="invoice.unknown_customer.national_code" />
</div>
} @else {
<p class="text-text-color pt-3 pb-5 text-center">اطلاعات مشتری ثبت نشده است.</p>
}
}
<p-divider align="center"> کالاهای خریداری شده </p-divider>
<app-page-data-list
[columns]="columns"
[items]="invoice.items"
[loading]="loading"
pageTitle=""
[showRefresh]="false">
<ng-template #totalAmount let-item>
@if (!item.discount_amount) {
<span [appPriceMask]="item.total_amount"></span>
}
</ng-template>
</app-page-data-list>
</div>
</p-card>
<shared-sale-invoice-single-info-card [loading]="loading" [invoice]="invoice" [variant]="variant" />
</div>
@if (moreActionMenuItems().length) {
@@ -132,4 +43,14 @@
[invoiceDate]="invoice.invoice_date"
(onSubmit)="correctionSubmit($event)" />
</shared-light-bottomsheet>
<shared-light-bottomsheet [(visible)]="showQrCodeDialog" header="کد QR">
<div class="flex flex-col gap-4">
<span class="text-center text-lg font-bold">
برای امکان دسترسی به صورت‌حساب به صورت آنلاین، کد QR را اسکن کنید.
</span>
<p-card class="mx-auto">
<qrcode [qrdata]="publicInvoiceRoute()" [width]="220" [errorCorrectionLevel]="'M'"></qrcode>
</p-card>
</div>
</shared-light-bottomsheet>
}
@@ -1,26 +1,17 @@
import { Maybe } from '@/core';
import { NativeBridgeService } from '@/core/services';
import { NavigationService } from '@/core/services/navigation.service';
import { ToastService } from '@/core/services/toast.service';
import { PosConfigPrintService } from '@/domains/pos/modules/configs/components/print/services/main.service';
import { IPosReturnFromSaleRequest } from '@/domains/pos/modules/saleInvoices/models/returnFromSale';
import { IPosOrderItem } from '@/domains/pos/modules/shop/models';
import { PosInfoStore } from '@/domains/pos/store';
import {
CatalogInvoiceTypeTagComponent,
CatalogTaxProviderStatusTagComponent,
TspProviderResponseStatus,
} from '@/shared/catalog';
import { CatalogInvoiceSettlementTypeTagComponent } from '@/shared/catalog/invoiceSettlementType';
import { KeyValueComponent, SharedLightBottomsheetComponent } from '@/shared/components';
import { TspProviderResponseStatus } from '@/shared/catalog';
import { SharedLightBottomsheetComponent } from '@/shared/components';
import { ISaleInvoiceFullResponse } from '@/shared/components/invoices/sale-invoice-full-response.model';
import { PageLoadingComponent } from '@/shared/components/page-loading.component';
import {
IColumn,
PageDataListComponent,
} from '@/shared/components/pageDataList/page-data-list.component';
import { PriceMaskDirective } from '@/shared/directives';
import { UikitEmptyStateComponent } from '@/uikit';
import { formatWithCurrency } from '@/utils';
import { formatJalali, formatWithCurrency } from '@/utils';
import {
Component,
computed,
@@ -33,12 +24,10 @@ import {
TemplateRef,
ViewChild,
} from '@angular/core';
import { UrlTree } from '@angular/router';
import { Router } from '@angular/router';
import { QRCodeComponent } from 'angularx-qrcode';
import { Button, ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
import { Dialog } from 'primeng/dialog';
import { Divider } from 'primeng/divider';
import { ProgressSpinner } from 'primeng/progressspinner';
import { TableModule } from 'primeng/table';
import { Observable } from 'rxjs';
import { AppConfirmationService } from '../confirmationDialog/confirmation-dialog.service';
@@ -46,8 +35,11 @@ import { InnerPagesHeaderComponent } from '../innerPagesHeader/inner-pages-heade
import { SharedCorrectionFormComponent } from './correctionForm';
import { CorrectionInvoiceFormValue, ICorrectionRequest, ReturnFromSaleFormValue } from './models';
import { SharedReturnFormComponent } from './returnForm/form.component';
import {
SaleInvoiceSingleInfoCardComponent,
TSaleInvoiceSingleViewVariant,
} from './sale-invoice-single-info-card.component';
export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos';
type TActionMenuItem = {
label: string;
icon: string;
@@ -60,24 +52,17 @@ type TActionMenuItem = {
selector: 'shared-sale-invoice-single-view',
templateUrl: './sale-invoice-single-view.component.html',
imports: [
KeyValueComponent,
Divider,
PageDataListComponent,
TableModule,
PageLoadingComponent,
UikitEmptyStateComponent,
PriceMaskDirective,
CatalogTaxProviderStatusTagComponent,
CatalogInvoiceTypeTagComponent,
Button,
CatalogInvoiceSettlementTypeTagComponent,
SharedLightBottomsheetComponent,
ProgressSpinner,
SharedReturnFormComponent,
SharedCorrectionFormComponent,
Dialog,
ButtonDirective,
InnerPagesHeaderComponent,
SaleInvoiceSingleInfoCardComponent,
QRCodeComponent,
Card,
],
})
@@ -88,11 +73,13 @@ export class SharedSaleInvoiceSingleViewComponent {
//TODO: below service Must be transform from pos to shared.
private readonly service = inject(PosConfigPrintService);
private readonly confirmationService = inject(AppConfirmationService);
private readonly router = inject(Router);
private readonly navigationService = inject(NavigationService);
@Input({ required: true }) loading!: boolean;
@Input() invoice!: Maybe<ISaleInvoiceFullResponse>;
@Input() variant: TSaleInvoiceSingleViewVariant = 'full';
@Input() backRoute?: UrlTree | string | any[];
@Input() backRoute?: string;
@Input() sendToTspLoading: boolean = false;
@Input() resendToTspLoading: boolean = false;
@Input() inquiryLoading: boolean = false;
@@ -109,6 +96,14 @@ export class SharedSaleInvoiceSingleViewComponent {
moreActionMenuItems = signal<TActionMenuItem[]>([]);
showCorrectionForm = signal(false);
showReturnFromSaleForm = signal(false);
showQrCodeDialog = signal(false);
publicInvoiceRoute = computed(() => {
if (this.invoice) {
return `${window.location.origin}/sale-invoices/${this.invoice.id}`;
}
return '';
});
showErrors = () => {};
inquiry = () => {
@@ -231,39 +226,39 @@ export class SharedSaleInvoiceSingleViewComponent {
preparePrint = async () => {
if (this.invoice) {
const mustPrintConfig = await this.service.get();
const mustPrintConfig = await this.service.getVisibleItems();
const defaultPrintItems = [
{
title: 'اطلاعات صورت‌حساب',
items: [
{
label: 'شماره',
value: this.invoice.invoice_number.toString(),
show: true,
},
{
label: 'شماره منحصر به فرد مالیاتی',
value: 'this.invoice',
value: this.invoice.tax_id || '-',
show: true,
},
{
label: 'شماره صورت‌حساب',
value: this.invoice.code,
show: true,
},
{
label: 'موضوع صورت‌حساب',
value: 'this.invoice',
show: mustPrintConfig.invoice_template ?? true,
},
{
label: 'نوع صورت‌حساب',
value: 'this.invoice',
label: 'نوع',
value: this.invoice.type.translate,
show: mustPrintConfig.invoice_template,
},
{
label: 'تاریخ و ساعت',
value: this.invoice.invoice_date,
label: 'موضوع',
value: this.invoice.pos.complex.business_activity.guild.name,
show: mustPrintConfig.invoice_template ?? true,
},
{
label: 'تاریخ',
value: formatJalali(this.invoice.invoice_date),
show: true,
},
{
label: 'وضعیت صورت‌حساب مالیاتی',
label: 'وضعیت صدور',
value: this.invoice.status.translate,
show: mustPrintConfig.show_payment_info ?? true,
},
@@ -273,23 +268,23 @@ export class SharedSaleInvoiceSingleViewComponent {
title: `اطلاعات فروشنده`,
items: [
{
label: 'عنوان فروشگاه',
label: 'فروشگاه',
value: this.invoice.pos.complex.business_activity.name,
show: mustPrintConfig.business_name,
},
{
label: 'عنوان شعبه',
label: 'شعبه',
value: this.invoice.pos.complex.name,
show: mustPrintConfig.complex_name,
},
{
label: 'عنوان پایانه‌ فروش',
label: 'پایانه‌ فروش',
value: this.invoice.pos.name,
show: mustPrintConfig.pos_name,
},
{
label: 'شماره اقتصادی',
value: 'this.invoice.pos.complex.business_activity.economic_code',
value: this.invoice.pos.complex.business_activity.economic_code,
show: mustPrintConfig.economic_code,
},
].filter((item) => item.show),
@@ -328,59 +323,67 @@ export class SharedSaleInvoiceSingleViewComponent {
items: [
{
label: 'شرح',
value: item.good.name,
show: mustPrintConfig.items?.name,
value: item.good_snapshot.name,
show: mustPrintConfig.item_name ?? true,
},
{
label: 'شناسه کالا / خدمت',
value: item.sku_code,
show: mustPrintConfig.items?.sku,
show: mustPrintConfig.item_sku ?? true,
},
{
label: 'تعداد / مقدار',
value: `${item.quantity} ${item.measure_unit_text}`,
show: mustPrintConfig.items?.quantity,
show: mustPrintConfig.item_quantity ?? true,
},
{
label: 'قیمت واحد',
value: formatWithCurrency(item.unit_price),
show: mustPrintConfig.items?.base_amount,
show: mustPrintConfig.item_base_amount ?? true,
},
{
label: 'اجرت',
value: formatWithCurrency(item.payload.wages),
show: mustPrintConfig.items?.wage && item.good_snapshot.pricing_model === 'GOLD',
show:
(mustPrintConfig.item_wage ?? true) &&
item.good_snapshot.pricing_model === 'GOLD',
},
{
label: 'سود',
value: formatWithCurrency(item.payload.wages),
show: mustPrintConfig.items?.profit && item.good_snapshot.pricing_model === 'GOLD',
value: formatWithCurrency(item.payload.profit),
show:
(mustPrintConfig.item_profit ?? true) &&
item.good_snapshot.pricing_model === 'GOLD',
},
{
label: 'حق‌العمل',
value: formatWithCurrency(item.payload.commission),
show:
mustPrintConfig.items?.commission && item.good_snapshot.pricing_model === 'GOLD',
(mustPrintConfig.item_commission ?? true) &&
item.good_snapshot.pricing_model === 'GOLD',
},
{
label: 'مالیات بر ارزش افزوده',
value: 'item.good.tax',
show: mustPrintConfig.items?.tax,
value: formatWithCurrency(item.tax_amount),
show: mustPrintConfig.item_tax ?? true,
},
{
label: 'تخفیف',
value: formatWithCurrency(item.discount),
show: mustPrintConfig.items?.discount,
value: formatWithCurrency(item.discount_amount),
show: mustPrintConfig.item_discount ?? true,
},
{
label: 'مبلغ کل',
value: formatWithCurrency(item.total_amount),
show: mustPrintConfig.items?.total_amount,
show: mustPrintConfig.item_total_amount ?? true,
},
],
});
}
}
console.log(defaultPrintItems);
return defaultPrintItems;
}
return null;
@@ -389,6 +392,7 @@ export class SharedSaleInvoiceSingleViewComponent {
printInvoice = async () => {
try {
const printItems = await this.preparePrint();
if (printItems) {
this.nativeBridge.print(printItems);
}
@@ -399,75 +403,6 @@ export class SharedSaleInvoiceSingleViewComponent {
}
};
columns: IColumn[] = [
{
field: 'name',
header: 'عنوان',
type: 'nested',
nestedOption: {
path: 'good_snapshot.name',
},
},
{
field: 'sku_code',
header: 'شناسه کالا',
},
{
field: 'unit_price',
header: 'قیمت واحد',
type: 'price',
},
{
field: 'quantity',
header: 'مقدار',
customDataModel(item) {
return `${item.quantity} ${item.measure_unit_text}`;
},
},
{
field: 'commission',
header: 'کارمزد',
type: 'nested',
nestedOption: {
path: 'payload.commission',
type: 'price',
},
},
{
field: 'wages',
header: 'اجرت',
type: 'nested',
nestedOption: {
path: 'payload.wages',
type: 'price',
},
},
{
field: 'profit',
header: 'سود',
type: 'nested',
nestedOption: {
path: 'payload.profit',
type: 'price',
},
},
{
field: 'discount_amount',
header: 'مبلغ تخفیف',
type: 'price',
},
{
field: 'tax_amount',
header: 'مبلغ مالیات',
type: 'price',
},
{
field: 'total_amount',
header: 'مبلغ قابل پرداخت',
type: 'price',
},
];
refresh() {
this.onRefresh.emit();
}
@@ -503,10 +438,10 @@ export class SharedSaleInvoiceSingleViewComponent {
const quantity = Number(item.quantity || 0);
const unit_price = Number(source.unit_price || 0);
const discount_amount = Number(source.discount || 0);
const discount_amount = Number(source.discount_amount || 0);
const base_total_amount = unit_price * quantity;
const total_amount = Number(source.total_amount || 0);
const tax_amount = Number(source.payload?.wages || 0);
const tax_amount = Number(source.tax_amount || 0);
return {
good_id: source.good_id,
@@ -537,4 +472,11 @@ export class SharedSaleInvoiceSingleViewComponent {
returnSubmit(event: ReturnFromSaleFormValue) {
this.sendReturnFromSale(this.mapReturnFromSaleRequest(event));
}
backToPrevPage() {
this.navigationService.back(this.backRoute);
}
showQrCode() {
this.showQrCodeDialog.set(true);
}
}
@@ -60,7 +60,7 @@
[discountAmount]="form.controls.discount_amount.value || 0"
[taxAmount]="taxAmount()" />
@if (!isCorrection) {
<button pButton class="w-full sm:w-auto" (click)="submit()">{{ preparedCTAText() }}</button>
<button pButton class="w-full sm:w-auto" size="large" (click)="submit()">{{ preparedCTAText() }}</button>
}
</div>
</div>
@@ -25,7 +25,7 @@
[discountAmount]="discountAmount()"
[taxAmount]="taxAmount()" />
@if (!isCorrection) {
<button pButton class="w-full sm:w-auto" (onClick)="submit()">{{ preparedCTAText() }}</button>
<button pButton class="w-full sm:w-auto" size="large" (onClick)="submit()">{{ preparedCTAText() }}</button>
}
</div>
</form>
@@ -2,13 +2,14 @@
<div class="mt-1">
<div class="flex flex-col gap-4">
<uikit-label name="rapidInvoice"> انتخاب سال</uikit-label>
<select
<p-select
[(ngModel)]="selectedYearDraft"
class="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-slate-700 outline-none">
@for (yearItem of years; track yearItem.value) {
<option [value]="yearItem.value">{{ yearItem.year }}</option>
}
</select>
appendTo="body"
class="w-full"
[options]="years"
optionLabel="year"
optionValue="value">
</p-select>
</div>
</div>
@@ -4,6 +4,7 @@ import { CommonModule } from '@angular/common';
import { Component, computed, EventEmitter, Input, Output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonDirective, ButtonSeverity } from 'primeng/button';
import { Select } from 'primeng/select';
import { SharedLightBottomsheetComponent } from '../dialog/light-bottomsheet.component';
type SeasonKey = 0 | 1 | 2 | 3;
@@ -23,6 +24,7 @@ interface SeasonItem {
SharedLightBottomsheetComponent,
ButtonDirective,
UikitLabelComponent,
Select,
],
})
export class SeasonPickerDialogComponent extends AbstractDialog {
@@ -1,9 +1,21 @@
<div class="border-surface-border bg-surface-card flex items-center gap-2 overflow-hidden rounded-lg border p-2">
<p-button icon="pi pi-chevron-right" size="small" text class="shrink-0" (onClick)="prevSeason()" />
<p-button
icon="pi pi-chevron-right"
size="small"
text
class="shrink-0"
[disabled]="!canGoPrev()"
(onClick)="prevSeason()" />
<div class="flex flex-1 cursor-pointer justify-center" (click)="openPicker()">
<span class="text-base font-semibold">{{ currentSeason() }}</span>
</div>
<p-button icon="pi pi-chevron-left" size="small" text class="shrink-0" (onClick)="nextSeason()" />
<p-button
icon="pi pi-chevron-left"
size="small"
text
class="shrink-0"
[disabled]="!canGoNext()"
(onClick)="nextSeason()" />
</div>
@if (isOpen()) {
@@ -18,7 +18,7 @@ interface SeasonItem {
imports: [CommonModule, Button, SeasonPickerDialogComponent],
})
export class SeasonPickerComponent implements OnInit {
@Input() minYear = 1390;
@Input() minYear = 1404;
@Input() maxYear = Number(nowJalali(JALALI_DATE_FORMATS.YEAR));
@Input() value!: Date;
@@ -66,13 +66,29 @@ export class SeasonPickerComponent implements OnInit {
this.isOpen.set(false);
}
canGoPrev(): boolean {
const index = this.selectedSeason();
const currentYear = this.selectedYear();
const nextSeason = index === 0 ? 3 : ((index - 1) as SeasonKey);
const nextYear = index === 0 ? currentYear - 1 : currentYear;
return this.isWithinBounds(nextYear, nextSeason);
}
canGoNext(): boolean {
const index = this.selectedSeason();
const currentYear = this.selectedYear();
const nextSeason = index === 3 ? 0 : ((index + 1) as SeasonKey);
const nextYear = index === 3 ? currentYear + 1 : currentYear;
return this.isWithinBounds(nextYear, nextSeason);
}
prevSeason(): void {
const index = this.selectedSeason();
const currentYear = this.selectedYear();
const nextSeason = index === 0 ? 3 : ((index - 1) as SeasonKey);
const nextYear = index === 0 ? currentYear - 1 : currentYear;
// if (!this.isWithinBounds(nextYear, nextSeason)) return;
if (!this.isWithinBounds(nextYear, nextSeason)) return;
this.selectedYear.set(nextYear);
this.selectedSeason.set(nextSeason);
@@ -85,14 +101,19 @@ export class SeasonPickerComponent implements OnInit {
const nextSeason = index === 3 ? 0 : ((index + 1) as SeasonKey);
const nextYear = index === 3 ? currentYear + 1 : currentYear;
if (!this.isWithinBounds(nextYear, nextSeason)) return;
this.selectedYear.set(nextYear);
this.selectedSeason.set(nextSeason);
this.emitValue();
}
submitPicker(value: { year: number; season: number }): void {
const season = value.season as SeasonKey;
if (!this.isWithinBounds(value.year, season)) return;
this.selectedYear.set(value.year);
this.selectedSeason.set(value.season as SeasonKey);
this.selectedSeason.set(season);
this.emitValue();
}
+1 -1
View File
@@ -61,7 +61,7 @@ export function formatWithCurrency(
currency: string | null | undefined = 'ریال',
opts?: { locale?: string; useComma?: boolean; fraction?: number }
): string {
if (num === null) return '0 ریال';
if (num === null || num === undefined) return '0 ریال';
const formatted = formatNumber(parseFloat(num + ''), opts);
if (isInputHost) return formatted;
if (currency && currency.trim()) return `${formatted} ${currency.trim()}`;
+2 -2
View File
@@ -1,9 +1,9 @@
// TIS tenant environment configuration
export const environment = {
production: true,
apiBaseUrl: 'https://psp-api.shift-am.ir',
// apiBaseUrl: 'https://psp-api.shift-am.ir',
// apiBaseUrl: 'http://192.168.128.73:5002',
// apiBaseUrl: 'http://192.168.0.162:5002',
apiBaseUrl: 'http://192.168.0.162:5002',
// apiBaseUrl: 'http://localhost:5002',
host: 'localhost',
port: 5000,