feat(pos): add shop and statistics modules with components, services, and routing

- Implemented shop module with root component, loading state, and invoice handling.
- Created statistics module with invoice type card component, API routes, and data models.
- Added season picker component for selecting year and season.
- Integrated services for fetching statistics data and managing state.
- Established routing for statistics and shop views.
This commit is contained in:
2026-05-23 18:09:44 +03:30
parent 8c07dc7c3f
commit 6ad1a73c16
100 changed files with 768 additions and 231 deletions
+4
View File
@@ -209,6 +209,8 @@
".webp": "file" ".webp": "file"
}, },
"styles": [ "styles": [
"node_modules/flatpickr-wrap/dist/flatpickr.css",
"node_modules/flatpickr-wrap/dist/themes/confetti.css",
"src/assets/styles.scss" "src/assets/styles.scss"
], ],
"tsConfig": "tsconfig.app.json" "tsConfig": "tsconfig.app.json"
@@ -250,6 +252,8 @@
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"karmaConfig": "karma.conf.js", "karmaConfig": "karma.conf.js",
"styles": [ "styles": [
"node_modules/flatpickr-wrap/dist/flatpickr.css",
"node_modules/flatpickr-wrap/dist/themes/confetti.css",
"src/assets/styles.scss" "src/assets/styles.scss"
], ],
"tsConfig": "tsconfig.spec.json" "tsConfig": "tsconfig.spec.json"
+3 -3
View File
@@ -3682,7 +3682,7 @@ packages:
hasBin: true hasBin: true
esbuild@0.28.0: esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==, tarball: https://hub.megan.ir/repository/npm/esbuild/-/esbuild-0.28.0.tgz}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
@@ -4122,7 +4122,7 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://hub.megan.ir/repository/npm/graceful-fs/-/graceful-fs-4.2.11.tgz}
handle-thing@2.0.1: handle-thing@2.0.1:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
@@ -6058,7 +6058,7 @@ packages:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1: source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, tarball: https://hub.megan.ir/repository/npm/source-map/-/source-map-0.6.1.tgz}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
source-map@0.7.6: source-map@0.7.6:
@@ -1,5 +1,5 @@
import ISummary from '@/core/models/summary'; import ISummary from '@/core/models/summary';
import { TCustomerInfo, TPosOrderGoodPayload } from '@/domains/pos/modules/landing/models'; import { TCustomerInfo, TPosOrderGoodPayload } from '@/domains/pos/modules/shop/models';
export interface ISalesInvoicesRawResponse { export interface ISalesInvoicesRawResponse {
id: string; id: string;
@@ -5,11 +5,12 @@ import { RouterLink } from '@angular/router';
import { Button } from 'primeng/button'; import { Button } from 'primeng/button';
import { Drawer } from 'primeng/drawer'; import { Drawer } from 'primeng/drawer';
import { Ripple } from 'primeng/ripple'; import { Ripple } from 'primeng/ripple';
import config from 'src/config';
import { posAboutNamedRoutes } from '../../modules/about/constants'; import { posAboutNamedRoutes } from '../../modules/about/constants';
import { posConfigNamedRoutes } from '../../modules/configs/constants'; import { posConfigNamedRoutes } from '../../modules/configs/constants';
import { PosGoodsManagementRoutes } from '../../modules/goodsManagement/constants'; import { PosGoodsManagementRoutes } from '../../modules/goodsManagement/constants';
import { posSaleInvoicesNamedRoutes } from '../../modules/saleInvoices/constants'; import { posSaleInvoicesNamedRoutes } from '../../modules/saleInvoices/constants';
import { PosShopNamedRoutes } from '../../modules/shop/constants/routes';
import { StatisticsRoutes } from '../../modules/statistics/constants';
import { posSupportNamedRoutes } from '../../modules/support/constants'; import { posSupportNamedRoutes } from '../../modules/support/constants';
import { PosInfoStore } from '../../store'; import { PosInfoStore } from '../../store';
@@ -37,10 +38,15 @@ export class PosMainMenuSidebarComponent extends AbstractDialog {
readonly menuItems = [ readonly menuItems = [
{ {
label: 'فروش', label: 'صفحه اصلی',
routerLink: config.isPosApplication ? '/' : '/pos', routerLink: StatisticsRoutes.statistics.meta.pagePath!(),
icon: 'pi pi-fw pi-home', icon: 'pi pi-fw pi-home',
}, },
{
label: 'فروش',
routerLink: PosShopNamedRoutes.shop.meta.pagePath!(),
icon: 'pi pi-fw pi-cart-arrow-down',
},
{ {
label: 'فاکتورها', label: 'فاکتورها',
routerLink: posSaleInvoicesNamedRoutes.saleInvoices.meta.pagePath!(), routerLink: posSaleInvoicesNamedRoutes.saleInvoices.meta.pagePath!(),
@@ -9,7 +9,7 @@ import { Message } from 'primeng/message';
import { catchError, finalize } from 'rxjs'; import { catchError, finalize } from 'rxjs';
import { COOKIE_KEYS } from 'src/assets/constants'; import { COOKIE_KEYS } from 'src/assets/constants';
import { IPosAccessibleResponse } from '../../models/pos.io'; import { IPosAccessibleResponse } from '../../models/pos.io';
import { PosService } from '../../modules/landing/services/main.service'; import { PosService } from '../../modules/shop/services/main.service';
@Component({ @Component({
selector: 'pos-choose-cards', selector: 'pos-choose-cards',
@@ -35,7 +35,7 @@ export class PosChooseCardsComponent {
catchError((err) => { catchError((err) => {
this.error.set(err); this.error.set(err);
throw err; throw err;
}), })
) )
.subscribe((res) => { .subscribe((res) => {
this.items.set(res?.data || []); this.items.set(res?.data || []);
@@ -1,8 +1,8 @@
<div class="w-full h-full flex items-center justify-center p-4"> <div class="flex h-full w-full items-center justify-center p-4">
<app-card-data cardTitle="تنظیمات فاکتور" class="w-full"> <app-card-data cardTitle="تنظیمات فاکتور" class="w-full">
<p-message variant="text" severity="info" icon="pi pi-info-circle"> <p-message variant="text" severity="info" icon="pi pi-info-circle">
هریک از موارد زیر را که می‌خواهید در چاپ فاکتور نمایش داده‌ شوند را انتخاب کنید هریک از موارد زیر را که می‌خواهید در چاپ فاکتور نمایش داده‌ شوند را انتخاب کنید
</p-message> </p-message>
<pos-config-print-form class="block w-full mt-6" (onClose)="returnToMainPage()" (onSubmit)="returnToMainPage()" /> <pos-config-print-form class="mt-6 block w-full" (onClose)="returnToMainPage()" (onSubmit)="returnToMainPage()" />
</app-card-data> </app-card-data>
</div> </div>
@@ -1,4 +1,5 @@
import { AppCardComponent } from '@/shared/components'; import { AppCardComponent } from '@/shared/components';
import { UikitFlatpickrJalaliComponent } from '@/uikit';
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Message } from 'primeng/message'; import { Message } from 'primeng/message';
@@ -7,7 +8,7 @@ import { PosConfigPrintFormComponent } from '../components/print/form.component'
@Component({ @Component({
selector: 'pos-config-page', selector: 'pos-config-page',
templateUrl: './root.component.html', templateUrl: './root.component.html',
imports: [PosConfigPrintFormComponent, AppCardComponent, Message], imports: [PosConfigPrintFormComponent, AppCardComponent, Message, UikitFlatpickrJalaliComponent],
}) })
export class PosConfigPageComponent { export class PosConfigPageComponent {
private readonly router = inject(Router); private readonly router = inject(Router);
@@ -1,28 +0,0 @@
<div class="flex items-center gap-2 justify-between">
<div class="flex flex-col">
<span class="font-bold text-lg">سفارش #{{ heldOrder.orderNumber }}</span>
<span class="text-sm text-muted-color">
{{ heldOrder.orderItems.length }} کالا -
<span [appPriceMask]="heldOrder.totalAmount"></span>
</span>
</div>
<div class="flex items-center gap-2">
<button
pButton
size="small"
type="button"
label="بارگذاری"
icon="pi pi-download"
(click)="loadHeldOrder(heldOrder.id)"
></button>
<button
pButton
size="small"
type="button"
label="حذف"
icon="pi pi-trash"
severity="danger"
(click)="cancelHeldOrder(heldOrder.id)"
></button>
</div>
</div>
@@ -1,57 +0,0 @@
import { ToastService } from '@/core/services/toast.service';
import { IPosHeldOrderResponse } from '@/modules/pos/models';
import { ConfirmationDialogService } from '@/shared/components/confirmationDialog/confirmation-dialog.service';
import { PriceMaskDirective } from '@/shared/directives';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { PosService } from '../../services/main.service';
@Component({
selector: 'app-held-order-item',
templateUrl: './held-order-item.component.html',
imports: [PriceMaskDirective, ButtonDirective],
})
export class HeldOrderItemComponent {
@Input() heldOrder!: IPosHeldOrderResponse;
@Input() posId!: number;
@Output() onCancel = new EventEmitter<number>();
@Output() onLoad = new EventEmitter<number>();
constructor(
private readonly posService: PosService,
private readonly confirmationService: ConfirmationDialogService,
private readonly toastService: ToastService,
) {}
actionLoading = signal<boolean>(false);
cancelHeldOrder(heldOrderId: number) {
this.confirmationService.confirm({
message: `آیا از لغو سفارش شماره‌ی #${this.heldOrder.orderNumber} اطمینان دارید؟`,
header: 'لغو سفارش موقت',
acceptLabel: 'بله',
rejectLabel: 'خیر',
accept: () => {
this.actionLoading.set(true);
// this.posService.cancelOrder(this.posId, heldOrderId).subscribe({
// next: () => {
// this.onCancel.emit(heldOrderId);
// this.actionLoading.set(false);
// this.toastService.success({
// text: `سفارش شماره‌ی #${this.heldOrder.orderNumber} با موفقیت لغو شد.`,
// });
// },
// error: () => {
// this.actionLoading.set(false);
// },
// });
},
reject: () => {},
});
}
loadHeldOrder(heldOrderId: number) {
this.onLoad.emit(heldOrderId);
}
}
@@ -1,19 +0,0 @@
<div class="d-flex flex-col gap-4">
<div class="flex items-center justify-between shrink-0">
<div class="flex items-center gap-2 text-muted-color">
<i class="pi pi-shopping-cart"></i>
<span class="text-base font-bold">سفارش‌های نگه‌داشته شده</span>
</div>
</div>
@if (!heldOrders()?.length) {
<div class="grow flex flex-col gap-5 justify-center items-center text-center text-muted-color">
<span class="text-base text-muted-color pt-4">هیچ سفارش نگه‌داشته شده‌ای وجود ندارد.</span>
</div>
} @else {
<div class="flex flex-col gap-2 mt-3">
@for (heldOrder of heldOrders(); track heldOrder.id) {
<app-held-order-item [heldOrder]="heldOrder" (onCancel)="removeHeldOrder($event)" />
}
</div>
}
</div>
@@ -1,39 +0,0 @@
import { Maybe } from '@/core';
import { IPosHeldOrderResponse } from '@/modules/pos/models';
import { Component, inject, signal } from '@angular/core';
import { PosService } from '../../services/main.service';
import { HeldOrderItemComponent } from './held-order-item.component';
@Component({
selector: 'app-held-orders',
templateUrl: './held-orders.component.html',
imports: [HeldOrderItemComponent],
})
export class HeldOrdersComponent {
private readonly service = inject(PosService);
heldOrders = signal<Maybe<IPosHeldOrderResponse[]>>(null);
loading = signal<boolean>(false);
ngOnInit() {
this.getHeldOrders();
}
getHeldOrders() {
this.loading.set(true);
// this.service.getHeldOrders().subscribe({
// next: (res) => {
// this.heldOrders.set(res.data);
// this.loading.set(false);
// },
// error: () => {
// this.loading.set(false);
// },
// });
}
removeHeldOrder(orderId: number) {
const currentOrders = this.heldOrders() || [];
this.heldOrders.set(currentOrders.filter((order) => order.id !== orderId));
}
}
@@ -50,7 +50,7 @@ export class PosSaleInvoicesFilterDrawerComponent implements OnChanges {
customer_mobile: this.fb.control<string>(''), customer_mobile: this.fb.control<string>(''),
customer_national_id: this.fb.control<string>(''), customer_national_id: this.fb.control<string>(''),
customer_economic_code: this.fb.control<string>(''), customer_economic_code: this.fb.control<string>(''),
status: this.fb.control<string | null>(null), status: this.fb.control<'NOT_SEND' | 'SUCCESS' | 'FAILURE' | 'QUEUED' | null>(null),
total_amount_from: this.fb.control<number | null>(null), total_amount_from: this.fb.control<number | null>(null),
total_amount_to: this.fb.control<number | null>(null), total_amount_to: this.fb.control<number | null>(null),
}); });
@@ -74,7 +74,7 @@ export class PosSaleInvoicesFilterDrawerComponent implements OnChanges {
total_amount_from: this.value.total_amount_from ?? null, total_amount_from: this.value.total_amount_from ?? null,
total_amount_to: this.value.total_amount_to ?? null, total_amount_to: this.value.total_amount_to ?? null,
}, },
{ emitEvent: false }, { emitEvent: false }
); );
} }
} }
@@ -1,7 +1,8 @@
import { POS_ROUTES } from '@/domains/pos/routes'; import { POS_ROUTES } from '@/domains/pos/routes';
import { InnerPagesHeaderComponent } from '@/shared/components/innerPagesHeader/inner-pages-header.component'; import { InnerPagesHeaderComponent } from '@/shared/components/innerPagesHeader/inner-pages-header.component';
import { gregorianToJalali } from '@/utils';
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Button, ButtonDirective } from 'primeng/button'; import { Button, ButtonDirective } from 'primeng/button';
import { Chip } from 'primeng/chip'; import { Chip } from 'primeng/chip';
import { Skeleton } from 'primeng/skeleton'; import { Skeleton } from 'primeng/skeleton';
@@ -32,6 +33,7 @@ import { SaleInvoiceCardComponent } from './sale-invoice-card.component';
export class PosSaleInvoiceListComponent { export class PosSaleInvoiceListComponent {
private readonly service = inject(PosSaleInvoicesService); private readonly service = inject(PosSaleInvoicesService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
loading = signal(true); loading = signal(true);
items = signal<IPosSaleInvoicesSummaryResponse[]>([]); items = signal<IPosSaleInvoicesSummaryResponse[]>([]);
@@ -57,16 +59,28 @@ export class PosSaleInvoiceListComponent {
.filter(([, value]) => value !== undefined && value !== null && value !== '') .filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => { .map(([key, value]) => {
const typedKey = key as keyof IPosSaleInvoicesFilterDto; const typedKey = key as keyof IPosSaleInvoicesFilterDto;
const formattedValue = let formattedValue = '';
typedKey === 'status' && value === 'NOT_SEND' if (typedKey === 'status') {
? 'ارسال نشده' formattedValue =
: typedKey === 'status' && value === 'SUCCESS' value === 'NOT_SEND'
? 'موفق' ? 'ارسال نشده'
: typedKey === 'status' && value === 'FAILURE' : value === 'SUCCESS'
? 'ناموفق' ? 'موفق'
: typedKey === 'status' && value === 'QUEUED' : value === 'FAILURE'
? 'در انتظار ارسال' ? 'ناموفق'
: String(value); : value === 'QUEUED'
? 'در انتظار ارسال'
: String(value);
} else if (
key === 'invoice_date_from' ||
key === 'invoice_date_to' ||
key === 'created_at_from' ||
key === 'created_at_to'
) {
formattedValue = gregorianToJalali(value);
} else {
formattedValue = String(value);
}
return { key: typedKey, label: labels[typedKey] ?? typedKey, value: formattedValue }; return { key: typedKey, label: labels[typedKey] ?? typedKey, value: formattedValue };
}); });
}); });
@@ -74,6 +88,7 @@ export class PosSaleInvoiceListComponent {
backRoute = POS_ROUTES.path || '/'; backRoute = POS_ROUTES.path || '/';
ngOnInit() { ngOnInit() {
this.hydrateFiltersFromQueryParams();
this.getData(); this.getData();
} }
@@ -96,6 +111,7 @@ export class PosSaleInvoiceListComponent {
onFilterApply(query: IPosSaleInvoicesFilterDto) { onFilterApply(query: IPosSaleInvoicesFilterDto) {
this.filters.set(query); this.filters.set(query);
this.syncFiltersToQueryParams();
this.getData(); this.getData();
} }
@@ -103,10 +119,57 @@ export class PosSaleInvoiceListComponent {
const next = { ...this.filters() }; const next = { ...this.filters() };
delete next[key]; delete next[key];
this.filters.set(next); this.filters.set(next);
this.syncFiltersToQueryParams();
this.getData(); this.getData();
} }
toSinglePage(item: IPosSaleInvoicesResponse) { toSinglePage(item: IPosSaleInvoicesResponse) {
this.router.navigateByUrl(posSaleInvoicesNamedRoutes.saleInvoice.meta.pagePath!(item.id)); this.router.navigateByUrl(posSaleInvoicesNamedRoutes.saleInvoice.meta.pagePath!(item.id));
} }
private hydrateFiltersFromQueryParams() {
const queryParams = this.route.snapshot.queryParams;
const keys: (keyof IPosSaleInvoicesFilterDto)[] = [
'invoice_date_from',
'invoice_date_to',
'created_at_from',
'created_at_to',
'code',
'customer_name',
'customer_mobile',
'customer_national_id',
'customer_economic_code',
'status',
'total_amount_from',
'total_amount_to',
];
const parsed = keys.reduce<IPosSaleInvoicesFilterDto>((acc, key) => {
const value = queryParams[key];
if (value !== undefined && value !== null && value !== '') {
acc[key] = value;
}
return acc;
}, {});
this.filters.set(parsed);
}
private syncFiltersToQueryParams() {
const queryParams = Object.entries(this.filters()).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = String(value);
}
return acc;
},
{}
);
this.router.navigate([], {
relativeTo: this.route,
queryParams,
replaceUrl: true,
});
}
} }
@@ -10,7 +10,7 @@ export interface IPosSaleInvoicesFilterDto {
customer_mobile?: string; customer_mobile?: string;
customer_national_id?: string; customer_national_id?: string;
customer_economic_code?: string; customer_economic_code?: string;
status?: string; status?: 'NOT_SEND' | 'SUCCESS' | 'FAILURE' | 'QUEUED';
total_amount?: number | null; total_amount?: number | null;
total_amount_from?: number | null; total_amount_from?: number | null;
total_amount_to?: number | null; total_amount_to?: number | null;
@@ -0,0 +1,20 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
import config from 'src/config';
export type TShopRouteNames = 'shop';
const baseRoute = `${config.isPosApplication ? '' : '/pos'}/shop`;
export const PosShopNamedRoutes: NamedRoutes<TShopRouteNames> = {
shop: {
path: 'shop',
loadComponent: () => import('../../views/root.component').then((m) => m.PosShopComponent),
meta: {
title: 'فروشگاه',
pagePath: () => baseRoute,
},
},
};
export const POS_SHOP_ROUTES: Routes = [PosShopNamedRoutes.shop];
@@ -14,7 +14,7 @@ import { IPosOrderResponse } from '../models';
import { PosLandingStore } from '../store/main.store'; import { PosLandingStore } from '../store/main.store';
@Component({ @Component({
selector: 'pos-landing', selector: 'pos-shop',
templateUrl: './root.component.html', templateUrl: './root.component.html',
host: { class: 'grow overflow-hidden' }, host: { class: 'grow overflow-hidden' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -29,7 +29,7 @@ import { PosLandingStore } from '../store/main.store';
PosOrderSubmittedDialogComponent, PosOrderSubmittedDialogComponent,
], ],
}) })
export class PosLandingComponent extends AbstractIsMobileComponent { export class PosShopComponent extends AbstractIsMobileComponent {
private readonly infoStore = inject(PosInfoStore); private readonly infoStore = inject(PosInfoStore);
private readonly landingStore = inject(PosLandingStore); private readonly landingStore = inject(PosLandingStore);
@@ -0,0 +1 @@
export * from './invoice-type-card.component';
@@ -0,0 +1,33 @@
<div
class="text-surface-700 relative flex flex-col gap-4 overflow-hidden rounded-2xl border px-4 py-4 pe-2"
[style.backgroundColor]="preparedInfo().bgColor"
[style.borderColor]="preparedInfo().borderColor"
(click)="showDetails()">
<div class="absolute top-0 right-0 h-full w-1" [style.backgroundColor]="preparedInfo().borderColor"></div>
<div class="text-surface-700 flex items-center justify-between gap-2">
<div class="text-surface-700 flex items-center gap-2">
<span
class="text-surface-700 flex h-8 w-8 items-center justify-center rounded-full border"
[style.borderColor]="preparedInfo().borderColor"
[style.backgroundColor]="preparedInfo().borderColor + '33'">
<i class="text-sm" [ngClass]="'pi pi-' + preparedInfo().icon" [style.color]="preparedInfo().borderColor"></i>
</span>
<span class="text-surface-700 text-sm font-semibold">{{ preparedInfo().title }}</span>
</div>
<span
class="text-surface-700 rounded-full border px-2 py-1 text-xs font-bold"
[style.borderColor]="preparedInfo().borderColor">
{{ count || 0 }} عدد
</span>
</div>
<div class="text-surface-700 grid grid-cols-1 gap-2">
<div class="flex items-center gap-2">
<div class="text-surface-700/80 text-xs">مبلغ کل:</div>
<div class="text-surface-700 mt-1 text-sm font-bold">{{ formattedTotalAmount }}</div>
</div>
<div class="flex items-center gap-2">
<div class="text-surface-700/80 text-xs">مالیات کل:</div>
<div class="text-surface-700 mt-1 text-sm font-bold">{{ formattedTotalTax }}</div>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
import { formatWithCurrency } from '@/utils';
import { CommonModule } from '@angular/common';
import { Component, computed, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'pos-statistics-invoice-type-card',
templateUrl: 'invoice-type-card.component.html',
imports: [CommonModule],
})
export class PosStatisticsInvoiceTypeCardComponent {
@Input() type: 'all' | 'success' | 'failure' | 'notSended' | 'pending' | 'credit' = 'all';
@Input() count?: number = 0;
@Input() totalAmount?: number = 0;
@Input() totalTaxAmount?: number = 0;
@Input() loading = false;
@Input() invoicesRoute = '';
@Output() onClick = new EventEmitter<void>();
get formattedTotalAmount() {
return formatWithCurrency(this.totalAmount || 0);
}
get formattedTotalTax() {
return formatWithCurrency(this.totalTaxAmount || 0);
}
readonly preparedInfo = computed(() => {
switch (this.type) {
case 'all':
return {
title: 'همه',
icon: 'close',
bgColor: '#6B72801A',
borderColor: '#6B7280',
};
case 'success':
return {
title: 'تایید شده',
icon: 'check',
bgColor: '#22C55E1A',
borderColor: '#22C55E',
};
case 'failure':
return {
title: 'خطادار',
icon: 'times',
bgColor: '#EF44441A',
borderColor: '#EF4444',
};
case 'notSended':
return {
title: 'ارسال نشده',
icon: 'clock',
bgColor: '#F59E0B1A',
borderColor: '#F59E0B',
};
case 'pending':
return {
title: 'انتظار ارسال',
icon: 'clock',
bgColor: '#3B82F61A',
borderColor: '#3B82F6',
};
case 'credit':
return {
title: 'اعتبار',
icon: 'credit-card',
bgColor: '#8B5CF61A',
borderColor: '#8B5CF6',
};
}
});
showDetails() {
this.onClick.emit();
}
}
@@ -0,0 +1,5 @@
const baseUrl = () => `/api/v1/pos/statistics`;
export const POS_STATISTICS_API_ROUTES = {
getAll: () => `${baseUrl()}/sale-invoices`,
};
@@ -0,0 +1,2 @@
export * from './apiRoutes/index';
export * from './routes/index';
@@ -0,0 +1,21 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
import config from 'src/config';
export type TStatisticsRouteNames = 'statistics';
const baseRoute = `${config.isPosApplication ? '' : '/pos'}`;
export const StatisticsRoutes: NamedRoutes<TStatisticsRouteNames> = {
statistics: {
path: '',
loadComponent: () =>
import('../../views/root.component').then((m) => m.PosStatisticsRootComponent),
meta: {
title: 'داشبودر',
pagePath: () => baseRoute,
},
},
};
export const POS_STATISTICS_ROUTES: Routes = [StatisticsRoutes.statistics];
@@ -0,0 +1 @@
export * from './io';
+15
View File
@@ -0,0 +1,15 @@
export interface IPosStatisticsRawResponse {
all: IPosStatisticsItem;
success: IPosStatisticsItem;
failure: IPosStatisticsItem;
pending: IPosStatisticsItem;
notSended: IPosStatisticsItem;
credit: IPosStatisticsItem;
}
export interface IPosStatisticsResponse extends IPosStatisticsRawResponse {}
interface IPosStatisticsItem {
count: number;
total_amount: number;
total_tax: number;
}
@@ -0,0 +1,18 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { POS_STATISTICS_API_ROUTES } from '../constants';
import { IPosStatisticsRawResponse, IPosStatisticsResponse } from '../models';
@Injectable({ providedIn: 'root' })
export class PosStatisticsService {
constructor(private http: HttpClient) {}
private apiRoutes = POS_STATISTICS_API_ROUTES;
getAll(fromDate?: Date): Observable<IPosStatisticsResponse> {
return this.http.get<IPosStatisticsRawResponse>(this.apiRoutes.getAll(), {
params: { from_date: fromDate?.toISOString() || '' },
});
}
}
@@ -0,0 +1,50 @@
import { defaultBaseStateData, EntityState, EntityStore } from '@/core/state';
import { inject, Injectable } from '@angular/core';
import { catchError, finalize } from 'rxjs';
import { IPosStatisticsResponse } from '../models';
import { PosStatisticsService } from '../services/main.service';
interface PosStatisticsState extends EntityState<IPosStatisticsResponse> {
lastFromDate: Date;
}
@Injectable({
providedIn: 'root',
})
export class PosStatisticsStore extends EntityStore<IPosStatisticsResponse, PosStatisticsState> {
private readonly service = inject(PosStatisticsService);
constructor() {
super({
...defaultBaseStateData,
lastFromDate: new Date(),
});
}
getData(fromDate?: Date) {
if (fromDate && fromDate.getTime() === this._state().lastFromDate.getTime()) {
return;
}
this.patchState({ loading: true, lastFromDate: fromDate || new Date() });
this.service
.getAll(fromDate)
.pipe(
finalize(() => {
this.patchState({ loading: false });
}),
catchError((error) => {
this.setError(error);
throw error;
})
)
.subscribe((entity) => {
this.patchState({ entity });
});
}
override reset(): void {
this.setState({
...defaultBaseStateData,
lastFromDate: new Date(),
});
}
}
@@ -0,0 +1 @@
export * from './root.component';
@@ -0,0 +1,57 @@
<div class="flex flex-col gap-6 p-4">
<season-picker (onChange)="onDateChanged($event)" />
<div class="grid grid-cols-2 gap-4">
@if (loading()) {
@for (_ of [0, 1, 2, 3, 4, 5]; track $index) {
<div class="h-32 w-full">
<p-skeleton width="100%" height="100%"></p-skeleton>
</div>
}
} @else {
<pos-statistics-invoice-type-card
type="all"
[loading]="loading()"
[totalAmount]="item()?.all?.total_amount"
[totalTaxAmount]="item()?.all?.total_tax"
[count]="item()?.all?.count"
(onClick)="toInvoicesPage('all')" />
<pos-statistics-invoice-type-card
type="success"
[loading]="loading()"
[totalAmount]="item()?.success?.total_amount"
[totalTaxAmount]="item()?.success?.total_tax"
[count]="item()?.success?.count"
(onClick)="toInvoicesPage('success')" />
<pos-statistics-invoice-type-card
type="failure"
[loading]="loading()"
[totalAmount]="item()?.failure?.total_amount"
[totalTaxAmount]="item()?.failure?.total_tax"
[count]="item()?.failure?.count"
(onClick)="toInvoicesPage('failure')" />
<pos-statistics-invoice-type-card
type="notSended"
[loading]="loading()"
[totalAmount]="item()?.notSended?.total_amount"
[totalTaxAmount]="item()?.notSended?.total_tax"
[count]="item()?.notSended?.count"
(onClick)="toInvoicesPage('notSended')" />
<pos-statistics-invoice-type-card
type="pending"
[loading]="loading()"
[totalAmount]="item()?.pending?.total_amount"
[totalTaxAmount]="item()?.pending?.total_tax"
[count]="item()?.pending?.count"
(onClick)="toInvoicesPage('pending')" />
<pos-statistics-invoice-type-card
type="credit"
[loading]="loading()"
[totalAmount]="item()?.credit?.total_amount"
[totalTaxAmount]="item()?.credit?.total_tax"
[count]="item()?.credit?.count" />
}
</div>
<div class="fixed inset-x-0 bottom-0 p-4">
<button pButton [routerLink]="shopRoute" size="large" class="w-full">ایجاد صورت‌حساب جدید</button>
</div>
</div>
@@ -0,0 +1,76 @@
import { SeasonPickerComponent } from '@/shared/components/seasonPicker/season-picker.component';
import { formatGregorian, gregorianToJalali, parseJalali } from '@/utils';
import { Component, computed, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
import { posSaleInvoicesNamedRoutes } from '../../saleInvoices/constants';
import { IPosSaleInvoicesFilterDto } from '../../saleInvoices/models';
import { PosShopNamedRoutes } from '../../shop/constants/routes';
import { PosStatisticsInvoiceTypeCardComponent } from '../components';
import { PosStatisticsStore } from '../store/main.store';
@Component({
selector: 'pos-statistics-root',
templateUrl: './root.component.html',
imports: [
PosStatisticsInvoiceTypeCardComponent,
Skeleton,
SeasonPickerComponent,
ButtonDirective,
RouterLink,
],
})
export class PosStatisticsRootComponent {
private readonly store = inject(PosStatisticsStore);
private readonly router = inject(Router);
readonly item = computed(() => this.store.entity());
readonly loading = computed(() => this.store.loading());
readonly shopRoute = PosShopNamedRoutes.shop.meta.pagePath!();
selectedDate = signal(new Date());
ngOnInit() {
this.getData(this.selectedDate());
}
onDateChanged(date: Date) {
this.selectedDate.set(date);
this.getData(date);
}
getData(date?: Date) {
this.store.getData(date);
}
toInvoicesPage(type: string) {
const mainRoute = posSaleInvoicesNamedRoutes.saleInvoices.meta.pagePath!();
const queryParams: IPosSaleInvoicesFilterDto = {
invoice_date_from: this.selectedDate().toISOString(),
invoice_date_to: new Date(
formatGregorian(
parseJalali(gregorianToJalali(this.selectedDate())).add(3, 'months').set('day', 1)
)
).toISOString(),
};
switch (type) {
case 'success':
queryParams.status = 'SUCCESS';
break;
case 'failure':
queryParams.status = 'FAILURE';
break;
case 'notSended':
queryParams.status = 'NOT_SEND';
break;
case 'pending':
queryParams.status = 'QUEUED';
break;
}
const queryString = new URLSearchParams(queryParams as Record<string, string>).toString();
this.router.navigateByUrl(`${mainRoute}?${queryString}`);
}
}
+4 -5
View File
@@ -3,21 +3,20 @@ import { POS_ABOUT_ROUTES } from './modules/about/constants';
import { POS_CONFIG_ROUTES } from './modules/configs/constants'; import { POS_CONFIG_ROUTES } from './modules/configs/constants';
import { POS_GOODS_MANAGEMENT_ROUTES } from './modules/goodsManagement/constants'; import { POS_GOODS_MANAGEMENT_ROUTES } from './modules/goodsManagement/constants';
import { POS_SALE_INVOICES_ROUTES } from './modules/saleInvoices/constants'; import { POS_SALE_INVOICES_ROUTES } from './modules/saleInvoices/constants';
import { POS_SHOP_ROUTES } from './modules/shop/constants/routes';
import { POS_STATISTICS_ROUTES } from './modules/statistics/constants';
import { POS_SUPPORT_ROUTES } from './modules/support/constants'; import { POS_SUPPORT_ROUTES } from './modules/support/constants';
export const POS_ROUTES = { export const POS_ROUTES = {
path: '', path: '',
loadComponent: () => import('@/layout/default/app.layout.component').then((m) => m.AppLayout), loadComponent: () => import('@/layout/default/app.layout.component').then((m) => m.AppLayout),
children: [ children: [
{ ...POS_SHOP_ROUTES,
path: '',
loadComponent: () =>
import('./modules/landing/views/root.component').then((m) => m.PosLandingComponent),
},
...POS_SALE_INVOICES_ROUTES, ...POS_SALE_INVOICES_ROUTES,
...POS_ABOUT_ROUTES, ...POS_ABOUT_ROUTES,
...POS_SUPPORT_ROUTES, ...POS_SUPPORT_ROUTES,
...POS_CONFIG_ROUTES, ...POS_CONFIG_ROUTES,
...POS_GOODS_MANAGEMENT_ROUTES, ...POS_GOODS_MANAGEMENT_ROUTES,
...POS_STATISTICS_ROUTES,
], ],
} as Route; } as Route;
+2 -2
View File
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { catchError, finalize, map } from 'rxjs'; import { catchError, finalize, map } from 'rxjs';
import { IPosProfileResponse } from '../models'; import { IPosProfileResponse } from '../models';
import { PosService } from '../modules/landing/services/main.service'; import { PosService } from '../modules/shop/services/main.service';
interface PosProfileState extends EntityState<IPosProfileResponse> {} interface PosProfileState extends EntityState<IPosProfileResponse> {}
@@ -37,7 +37,7 @@ export class PosProfileStore extends EntityStore<IPosProfileResponse, PosProfile
this.setEntity(entity); this.setEntity(entity);
} }
return entity; return entity;
}), })
); );
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import { CookieService } from 'ngx-cookie-service';
import { catchError, finalize, map } from 'rxjs'; import { catchError, finalize, map } from 'rxjs';
import { COOKIE_KEYS } from 'src/assets/constants'; import { COOKIE_KEYS } from 'src/assets/constants';
import { IPosInfoResponse } from '../models/pos.io'; import { IPosInfoResponse } from '../models/pos.io';
import { PosService } from '../modules/landing/services/main.service'; import { PosService } from '../modules/shop/services/main.service';
interface PosState extends EntityState<IPosInfoResponse> { interface PosState extends EntityState<IPosInfoResponse> {
posId: string; posId: string;
@@ -44,7 +44,7 @@ export class PosInfoStore extends EntityStore<IPosInfoResponse, PosState> {
this.setEntity(entity); this.setEntity(entity);
} }
return entity; return entity;
}), })
); );
} }
@@ -35,8 +35,6 @@ export class PublicSaleInvoiceStore extends EntityStore<
}) })
) )
.subscribe((entity) => { .subscribe((entity) => {
console.log('entity', entity);
this.patchState({ entity }); this.patchState({ entity });
}); });
} }
@@ -0,0 +1,33 @@
<shared-light-bottomsheet [(visible)]="visible">
<div class="mt-1">
<label class="mb-1 block text-sm font-medium text-slate-600">انتخاب سال</label>
<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>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
@for (seasonItem of seasons; track seasonItem.key) {
<button
pButton
type="button"
[label]="seasonItem.label"
[severity]="seasonItem.severity"
outlined
(click)="selectedSeasonDraft.set(seasonItem.key)"></button>
}
<!-- [disabled]="selectedYear <= selectedYearDraft() && selectedSeason <= selectedSeasonDraft()" -->
</div>
<div class="mt-4">
<app-form-footer-actions
submitLabel="تایید"
cancelLabel="انصراف"
(onCancel)="visibleChange.emit(false)"
(onSubmit)="onSubmit()" />
</div>
</shared-light-bottomsheet>
@@ -0,0 +1,53 @@
import { AbstractDialog } from '@/shared/abstractClasses/abstract-dialog';
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 { SharedLightBottomsheetComponent } from '../dialog/light-bottomsheet.component';
import { FormFooterActionsComponent } from '../formFooterActions/form-footer-actions.component';
type SeasonKey = 0 | 1 | 2 | 3;
interface SeasonItem {
key: SeasonKey;
label: string;
severity: ButtonSeverity;
}
@Component({
selector: 'season-picker-dialog',
templateUrl: './season-picker-dialog.component.html',
imports: [
CommonModule,
FormsModule,
SharedLightBottomsheetComponent,
FormFooterActionsComponent,
ButtonDirective,
],
})
export class SeasonPickerDialogComponent extends AbstractDialog {
@Input() years: { year: number; value: number }[] = [];
@Input() selectedYear!: number;
@Input() selectedSeason!: SeasonKey;
@Input() seasons: SeasonItem[] = [];
@Output() submit = new EventEmitter<{ year: number; season: number }>();
selectedYearDraft = signal(this.selectedYear);
selectedSeasonDraft = signal(this.selectedSeason);
isSeasonDisabled = computed(() => this.selectedYear >= this.selectedYearDraft());
onSubmit(): void {
this.submit.emit({
year: this.selectedYearDraft(),
season: this.selectedSeasonDraft(),
});
this.close();
}
ngOnChanges() {
this.selectedYearDraft.set(this.selectedYear);
this.selectedSeasonDraft.set(this.selectedSeason);
}
}
@@ -0,0 +1,18 @@
<div class="flex items-center gap-2 overflow-hidden rounded-xl border border-slate-200 bg-white p-2">
<p-button icon="pi pi-chevron-right" size="small" text class="shrink-0" (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()" />
</div>
@if (isOpen()) {
<season-picker-dialog
[visible]="isOpen()"
[years]="years()"
[seasons]="seasons"
[selectedYear]="selectedYear()"
[selectedSeason]="selectedSeason()"
(visibleChange)="isOpen.set($event)"
(submit)="submitPicker($event)" />
}
@@ -0,0 +1,115 @@
import { gregorianToJalali, JALALI_DATE_FORMATS, jalaliToGregorian, nowJalali } from '@/utils';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnInit, Output, signal } from '@angular/core';
import { Button, ButtonSeverity } from 'primeng/button';
import { SeasonPickerDialogComponent } from './season-picker-dialog.component';
type SeasonKey = 0 | 1 | 2 | 3;
interface SeasonItem {
key: SeasonKey;
label: string;
severity: ButtonSeverity;
}
@Component({
selector: 'season-picker',
templateUrl: 'season-picker.component.html',
imports: [CommonModule, Button, SeasonPickerDialogComponent],
})
export class SeasonPickerComponent implements OnInit {
@Input() minYear = 1390;
@Input() maxYear = Number(nowJalali(JALALI_DATE_FORMATS.YEAR));
@Input() value!: Date;
@Output() onChange = new EventEmitter<Date>();
readonly isOpen = signal(false);
readonly selectedYear = signal(Number(nowJalali(JALALI_DATE_FORMATS.YEAR)));
readonly selectedSeason = signal<SeasonKey>(0);
readonly seasons: SeasonItem[] = [
{ key: 0, label: 'بهار', severity: 'success' },
{ key: 1, label: 'تابستان', severity: 'danger' },
{ key: 2, label: 'پاییز', severity: 'warn' },
{ key: 3, label: 'زمستان', severity: 'info' },
];
readonly years = signal<{ year: number; value: number }[]>([]);
ngOnInit(): void {
const year = Number(gregorianToJalali(this.value, JALALI_DATE_FORMATS.YEAR));
const monthIndex = Number(gregorianToJalali(this.value, JALALI_DATE_FORMATS.MONTH)) - 1;
const season = Math.floor(monthIndex / 3) as SeasonKey;
this.selectedYear.set(year);
this.selectedSeason.set(season);
this.years.set(
Array.from({ length: this.maxYear - this.minYear + 1 }, (_, i) => ({
year: this.minYear + i,
value: this.minYear + i,
}))
);
this.emitValue();
}
currentSeason(): string {
const season = this.seasons.find((item) => item.key === this.selectedSeason());
return `${season?.label ?? ''} ${this.selectedYear()}`;
}
openPicker(): void {
this.isOpen.set(true);
}
closePicker(): void {
this.isOpen.set(false);
}
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;
this.selectedYear.set(nextYear);
this.selectedSeason.set(nextSeason);
this.emitValue();
}
nextSeason(): void {
const index = this.selectedSeason();
const currentYear = this.selectedYear();
const nextSeason = index === 3 ? 0 : ((index + 1) as SeasonKey);
const nextYear = index === 3 ? currentYear + 1 : currentYear;
this.selectedYear.set(nextYear);
this.selectedSeason.set(nextSeason);
this.emitValue();
}
submitPicker(value: { year: number; season: number }): void {
this.selectedYear.set(value.year);
this.selectedSeason.set(value.season as SeasonKey);
this.emitValue();
}
private isWithinBounds(year: number, season: SeasonKey): boolean {
if (year < this.minYear || year > this.maxYear) return false;
// if (year === this.minYear && season < this.minSeason) return false;
// if (year === this.maxYear && season > this.maxSeason) return false;
return true;
}
private emitValue(): void {
this.onChange.emit(
new Date(
jalaliToGregorian(
`${this.selectedYear()}/${(this.selectedSeason() * 3 + 1).toString().padStart(2, '0')}/01`
)
)
);
}
}
@@ -1,18 +1,7 @@
<uikit-field <div #wrapper [attr.data-name]="name" class="flatWrapper w-full">
[label]="label" <input data-input [attr.placeholder]="placeholder" [name]="name" class="flatpicker-field w-full" />
[control]="control"
[showLabel]="showLabel"
[showErrors]="showErrors"
class="w-full datepicker"
>
<div class="w-full">
<div #wrapper [attr.data-disable]="disabled" [attr.data-name]="name" class="w-full">
<input pInputText data-input [attr.placeholder]="placeholder" [name]="name" class="w-full" />
@if (hint) { @if (hint) {
<small [attr.id]="name + '-help'">{{ hint }}</small> <small [attr.id]="name + '-help'">{{ hint }}</small>
} }
</div> </div>
<div class="flat"></div>
</div>
</uikit-field>
@@ -1,17 +1,20 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
import { jalaliToGregorian, nowJalali } from '@/utils';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
AfterViewInit,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
Input, Input,
OnInit, OnDestroy,
Output, Output,
ViewChild, ViewChild,
} from '@angular/core'; } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import flatpickr from 'flatpickr-wrap'; import flatpickr from 'flatpickr-wrap';
import { Persian } from 'flatpickr-wrap/dist/l10n/fa';
import { BaseOptions, DateOption } from 'flatpickr-wrap/dist/types/options'; import { BaseOptions, DateOption } from 'flatpickr-wrap/dist/types/options';
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from 'primeng/inputtext';
import { UikitFieldComponent } from '../uikit-field.component'; import { UikitFieldComponent } from '../uikit-field.component';
@@ -22,7 +25,7 @@ import { UikitFieldComponent } from '../uikit-field.component';
imports: [CommonModule, ReactiveFormsModule, InputTextModule, UikitFieldComponent], imports: [CommonModule, ReactiveFormsModule, InputTextModule, UikitFieldComponent],
templateUrl: './datepicker.component.html', templateUrl: './datepicker.component.html',
}) })
export class UikitFlatpickrJalaliComponent implements OnInit { export class UikitFlatpickrJalaliComponent implements AfterViewInit, OnDestroy {
@Input() control?: FormControl<Maybe<string>>; @Input() control?: FormControl<Maybe<string>>;
@Input() placeholder = 'انتخاب تاریخ'; @Input() placeholder = 'انتخاب تاریخ';
@Input() label = 'تاریخ'; @Input() label = 'تاریخ';
@@ -41,19 +44,34 @@ export class UikitFlatpickrJalaliComponent implements OnInit {
private fpInstance: any | null = null; private fpInstance: any | null = null;
ngOnInit(): void { ngAfterViewInit(): void {
this.fpInstance = flatpickr(this.wrapperRef.nativeElement, { this.fpInstance = flatpickr(this.wrapperRef.nativeElement, {
wrap: true,
...this.options, ...this.options,
locale: 'fa', locale: Persian,
disableMobile: true,
clickOpens: !this.disabled,
minDate: this.minDate, minDate: this.minDate,
maxDate: this.maxDate, maxDate: this.maxDate,
altInputClass: 'p-inputtext w-full', now: nowJalali(),
altInputClass: 'flatpicker-field w-full',
altInput: true,
dateFormat: 'Y-m-d', dateFormat: 'Y-m-d',
altFormat: 'Y-m-d', altFormat: 'Y/m/d',
onChange: (selectedDates: Date[], dateStr: string) => { onChange: (selectedDates: Date[], dateStr: string) => {
console.log('dateStr', jalaliToGregorian(dateStr));
this.valueChange.emit(dateStr); this.valueChange.emit(dateStr);
if (this.control) this.control.setValue(dayjs(dateStr).toISOString()); if (this.control) this.control.setValue(dayjs(dateStr).toISOString());
}, },
}); });
} }
ngOnDestroy(): void {
if (this.fpInstance?.destroy) {
this.fpInstance.destroy();
this.fpInstance = null;
}
}
} }
+17 -9
View File
@@ -29,6 +29,10 @@ export const JALALI_DATE_FORMATS = {
TIME_WITH_SECONDS: 'HH:mm:ss', TIME_WITH_SECONDS: 'HH:mm:ss',
/** Month and year: "شهریور ۱۳۹۷" */ /** Month and year: "شهریور ۱۳۹۷" */
MONTH_YEAR: 'MMMM YYYY', MONTH_YEAR: 'MMMM YYYY',
/** Year: "۱۳۹۷" */
YEAR: 'YYYY',
/** Month: "۰۶" */
MONTH: 'MM',
/** Day and month: "۱۳ شهریور" */ /** Day and month: "۱۳ شهریور" */
DAY_MONTH: 'DD MMMM', DAY_MONTH: 'DD MMMM',
} as const; } as const;
@@ -55,6 +59,10 @@ export const GREGORIAN_DATE_FORMATS = {
TIME_WITH_SECONDS: 'HH:mm:ss', TIME_WITH_SECONDS: 'HH:mm:ss',
/** Month and year: "September 2018" */ /** Month and year: "September 2018" */
MONTH_YEAR: 'MMMM YYYY', MONTH_YEAR: 'MMMM YYYY',
/** Year: "2018" */
YEAR: 'YYYY',
/** Month: "06" */
MONTH: 'MM',
/** Day and month: "September 04" */ /** Day and month: "September 04" */
DAY_MONTH: 'MMMM DD', DAY_MONTH: 'MMMM DD',
} as const; } as const;
@@ -73,7 +81,7 @@ export const GREGORIAN_DATE_FORMATS = {
*/ */
export function formatJalali( export function formatJalali(
date: string | Date | Dayjs | number, date: string | Date | Dayjs | number,
format: string = JALALI_DATE_FORMATS.NUMERIC, format: string = JALALI_DATE_FORMATS.NUMERIC
): string { ): string {
return dayjs(date).calendar('jalali').locale('fa').format(format); return dayjs(date).calendar('jalali').locale('fa').format(format);
} }
@@ -91,7 +99,7 @@ export function formatJalali(
*/ */
export function formatGregorian( export function formatGregorian(
date: string | Date | Dayjs | number, date: string | Date | Dayjs | number,
format: string = GREGORIAN_DATE_FORMATS.NUMERIC, format: string = GREGORIAN_DATE_FORMATS.NUMERIC
): string { ): string {
return dayjs(date).calendar('gregory').locale('en').format(format); return dayjs(date).calendar('gregory').locale('en').format(format);
} }
@@ -107,7 +115,7 @@ export function formatGregorian(
* parseJalali('1398-10-17') // Dayjs instance * parseJalali('1398-10-17') // Dayjs instance
* parseJalali('1398/10/17') // Dayjs instance * parseJalali('1398/10/17') // Dayjs instance
*/ */
export function parseJalali(jalaliDate: string, format?: string): Dayjs { export function parseJalali(jalaliDate: string | Date | Dayjs | number, format?: string): Dayjs {
return dayjs(jalaliDate, { jalali: true, ...(format && { format }) }); return dayjs(jalaliDate, { jalali: true, ...(format && { format }) });
} }
@@ -124,7 +132,7 @@ export function parseJalali(jalaliDate: string, format?: string): Dayjs {
*/ */
export function gregorianToJalali( export function gregorianToJalali(
gregorianDate: string | Date | Dayjs, gregorianDate: string | Date | Dayjs,
format: string = JALALI_DATE_FORMATS.NUMERIC, format: string = JALALI_DATE_FORMATS.NUMERIC
): string { ): string {
return dayjs(gregorianDate).calendar('jalali').locale('fa').format(format); return dayjs(gregorianDate).calendar('jalali').locale('fa').format(format);
} }
@@ -142,7 +150,7 @@ export function gregorianToJalali(
*/ */
export function jalaliToGregorian( export function jalaliToGregorian(
jalaliDate: string, jalaliDate: string,
format: string = GREGORIAN_DATE_FORMATS.NUMERIC, format: string = GREGORIAN_DATE_FORMATS.NUMERIC
): string { ): string {
return parseJalali(jalaliDate).calendar('gregory').locale('en').format(format); return parseJalali(jalaliDate).calendar('gregory').locale('en').format(format);
} }
@@ -245,7 +253,7 @@ export function formatDate(
date: string | Date | Dayjs | number, date: string | Date | Dayjs | number,
calendar: 'jalali' | 'gregory', calendar: 'jalali' | 'gregory',
locale: 'fa' | 'en', locale: 'fa' | 'en',
format: string, format: string
): string { ): string {
return dayjs(date).calendar(calendar).locale(locale).format(format); return dayjs(date).calendar(calendar).locale(locale).format(format);
} }
@@ -296,21 +304,21 @@ export function getJalaliWeekdayName(dayNumber: number): string {
export function jalaliDiff( export function jalaliDiff(
date1: string | number | Date | Dayjs, date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs, date2: string | number | Date | Dayjs,
unit: dayjs.OpUnitType = 'day', unit: dayjs.OpUnitType = 'day'
): number { ): number {
return toJalali(date1).diff(toJalali(date2), unit); return toJalali(date1).diff(toJalali(date2), unit);
} }
export function isJalaliBefore( export function isJalaliBefore(
date1: string | number | Date | Dayjs, date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs, date2: string | number | Date | Dayjs
): boolean { ): boolean {
return toJalali(date1).isBefore(toJalali(date2)); return toJalali(date1).isBefore(toJalali(date2));
} }
export function isJalaliAfter( export function isJalaliAfter(
date1: string | number | Date | Dayjs, date1: string | number | Date | Dayjs,
date2: string | number | Date | Dayjs, date2: string | number | Date | Dayjs
): boolean { ): boolean {
return toJalali(date1).isAfter(toJalali(date2)); return toJalali(date1).isAfter(toJalali(date2));
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
@use "./tailwind.css"; @use "./tailwind.css";
// @use "flatpickr-wrap/dist/themes/confetti.css"; @use "flatpickr-wrap/dist/themes/confetti.css";
@use "flatpickr-wrap/dist/flatpickr.css"; @use "flatpickr-wrap/dist/flatpickr.css";
@use "./flatpicker.css"; @use "./flatpicker.css";
@use "./layout/layout.scss"; @use "./layout/layout.scss";
+14 -16
View File
@@ -1,25 +1,23 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import flatpickr from 'flatpickr-wrap';
import faLocales from 'flatpickr-wrap/dist/l10n/fa.js';
import { brandingConfig } from './app/branding/branding.config';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { appConfig } from './app.config'; import { appConfig } from './app.config';
import { brandingConfig } from './app/branding/branding.config';
import config from './config'; import config from './config';
flatpickr.localize(faLocales.fa!); // flatpickr.localize(faLocales.fa!);
// Apply default configuration requested by the project // // Apply default configuration requested by the project
if (flatpickr.defaultConfig) { // if (flatpickr.defaultConfig) {
Object.assign(flatpickr.defaultConfig, { // Object.assign(flatpickr.defaultConfig, {
altInput: true, // altInput: true,
altFormat: 'F j, Y H:i', // altFormat: 'F j, Y H:i',
dateFormat: 'Y-m-d', // dateFormat: 'Y-m-d',
time_24hr: true, // time_24hr: true,
time_format: 'H:i', // time_format: 'H:i',
wrap: true, // wrap: true,
clickOpens: true, // clickOpens: true,
}); // });
} // }
document.title = brandingConfig.appTitle; document.title = brandingConfig.appTitle;