better dashboard style
This commit is contained in:
@@ -4,13 +4,17 @@ import type {
|
|||||||
DashboardFlowPoint,
|
DashboardFlowPoint,
|
||||||
DashboardFinance,
|
DashboardFinance,
|
||||||
DashboardMaster,
|
DashboardMaster,
|
||||||
|
DashboardOrderPeakHours,
|
||||||
PriceChartPoint,
|
PriceChartPoint,
|
||||||
} from '@/model/dashboard'
|
} from '@/model/dashboard'
|
||||||
import type { Order } from '@/model/order'
|
import type { Order } from '@/model/order'
|
||||||
import type { Balance } from '@/model/product'
|
import type { Balance } from '@/model/product'
|
||||||
import type { TableInput } from '@core/type/baseTable'
|
import type { TableInput } from '@core/type/baseTable'
|
||||||
|
|
||||||
export const getDashboard = async () => await useAxios.get<DashboardMaster>('/admin/dashboard')
|
export const getDashboard = async (params?: {
|
||||||
|
startDateProduct?: string
|
||||||
|
endDateProduct?: string
|
||||||
|
}) => await useAxios.get<DashboardMaster>('/admin/dashboard', { params })
|
||||||
|
|
||||||
export const updateMyInformation = async (val: User) =>
|
export const updateMyInformation = async (val: User) =>
|
||||||
await useAxios.post<User>('/admin/me/update', val)
|
await useAxios.post<User>('/admin/me/update', val)
|
||||||
@@ -24,6 +28,9 @@ export const getDashboardBalance = async (startDate: string, endDate: string) =>
|
|||||||
export const getDashboardFlow = async (type: 'week' | 'month') =>
|
export const getDashboardFlow = async (type: 'week' | 'month') =>
|
||||||
await useAxios.post<DashboardFlowPoint[]>('/admin/dashboard/customer/flow', { type })
|
await useAxios.post<DashboardFlowPoint[]>('/admin/dashboard/customer/flow', { type })
|
||||||
|
|
||||||
|
export const getDashboardOrderPeakHours = async (days: 7 | 30 = 30) =>
|
||||||
|
await useAxios.post<DashboardOrderPeakHours>('/admin/dashboard/customer/order-peak-hours', { days })
|
||||||
|
|
||||||
export const getPriceChartData = async () => {
|
export const getPriceChartData = async () => {
|
||||||
const res = await useAxios.post<{
|
const res = await useAxios.post<{
|
||||||
code?: number
|
code?: number
|
||||||
|
|||||||
@@ -57,6 +57,34 @@ export const setActiveTime = async (open: string, close: string) =>{
|
|||||||
await useAxios.put<SetActiveTime>(`/admin/products`, {auto_open_at: open, auto_close_at: close})
|
await useAxios.put<SetActiveTime>(`/admin/products`, {auto_open_at: open, auto_close_at: close})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductActivationSnapshot = {
|
||||||
|
has_snapshot: boolean
|
||||||
|
saved_at?: string | null
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProductActivationSnapshot = async () =>
|
||||||
|
await useAxios.get<ProductActivationSnapshot>('/admin/products/quick/activation-snapshot')
|
||||||
|
|
||||||
|
export const deactivateAllProducts = async () =>
|
||||||
|
await useAxios.post<{ message: string; snapshot_count: number; has_snapshot: boolean }>(
|
||||||
|
'/admin/products/quick/deactivate-all',
|
||||||
|
)
|
||||||
|
|
||||||
|
export const restoreProductActivation = async () =>
|
||||||
|
await useAxios.post<{ message: string; restored: number }>(
|
||||||
|
'/admin/products/quick/restore-activation',
|
||||||
|
)
|
||||||
|
|
||||||
|
export const closeAllProductBuy = async () =>
|
||||||
|
await useAxios.post<{ message: string }>('/admin/products/quick/close-buy')
|
||||||
|
|
||||||
|
export const closeAllProductSell = async () =>
|
||||||
|
await useAxios.post<{ message: string }>('/admin/products/quick/close-sell')
|
||||||
|
|
||||||
|
export const syncProductShow = async () =>
|
||||||
|
await useAxios.post<{ message: string }>('/admin/products/quick/sync-show')
|
||||||
|
|
||||||
export const getBalance = async (startDate: string, endDate: string) => {
|
export const getBalance = async (startDate: string, endDate: string) => {
|
||||||
return await useAxios.post<Balance>('/admin/getBalance', {startDate, endDate})
|
return await useAxios.post<Balance>('/admin/getBalance', {startDate, endDate})
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,21 @@ export interface DashboardFlowPoint {
|
|||||||
2?: number
|
2?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardOrderPeakHour {
|
||||||
|
hour: number
|
||||||
|
buy: number
|
||||||
|
sell: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOrderPeakHours {
|
||||||
|
hours: DashboardOrderPeakHour[]
|
||||||
|
peak_hour: number | null
|
||||||
|
peak_count: number
|
||||||
|
days: number
|
||||||
|
total_orders: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface PriceChartPoint {
|
export interface PriceChartPoint {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
value: number
|
value: number
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
class="dashboard-label-with-help inline-flex max-w-full items-center gap-1"
|
class="dashboard-label-with-help inline-flex max-w-full items-baseline gap-1.5"
|
||||||
:class="[textClass || sizeTextClass, rootClass]"
|
:class="[textClass || sizeTextClass, rootClass]"
|
||||||
>
|
>
|
||||||
<span class="truncate leading-none" :class="labelClass">{{ label }}</span>
|
<span class="leading-normal" :class="labelClass">{{ label }}</span>
|
||||||
<DashboardHelpTip v-if="help" :help="help" :size="size" />
|
<DashboardHelpTip v-if="help" :help="help" :size="size" />
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<router-link v-if="to" :to="to" class="dashboard-quick-action-link inline-flex">
|
||||||
|
<span :class="btnClass">
|
||||||
|
<span class="dashboard-quick-action-inner">
|
||||||
|
<span class="dashboard-quick-action__icon">
|
||||||
|
<BaseIcon :icon="icon" :size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="dashboard-quick-action__label">
|
||||||
|
<slot>{{ label }}</slot>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
<a-button
|
||||||
|
v-else
|
||||||
|
size="small"
|
||||||
|
:danger="variant === 'danger'"
|
||||||
|
:type="variant === 'primary' ? 'primary' : 'text'"
|
||||||
|
:class="btnClass"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<span class="dashboard-quick-action-inner">
|
||||||
|
<span class="dashboard-quick-action__icon">
|
||||||
|
<BaseIcon :icon="icon" :size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="dashboard-quick-action__label">
|
||||||
|
<slot>{{ label }}</slot>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
type Variant = 'default' | 'accent' | 'danger' | 'primary'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon: string
|
||||||
|
label?: string
|
||||||
|
variant?: Variant
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
to?: RouteLocationRaw
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
variant: 'default',
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ click: [] }>()
|
||||||
|
|
||||||
|
const btnClass = computed(() => [
|
||||||
|
'dashboard-quick-action-btn',
|
||||||
|
`dashboard-quick-action-btn--${props.variant}`,
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-quick-action-inner {
|
||||||
|
@apply inline-flex items-center gap-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action__icon {
|
||||||
|
@apply inline-flex shrink-0 items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action__icon :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 17px !important;
|
||||||
|
height: 17px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action__label {
|
||||||
|
@apply whitespace-nowrap text-xs font-medium leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-btn {
|
||||||
|
@apply !inline-flex !h-8 !items-center !justify-center !rounded-lg !border-white/20 !px-2.5 !py-0 !shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-btn--default {
|
||||||
|
@apply !border-white/20 !bg-white/15 !text-white hover:!bg-white/25 disabled:!opacity-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-btn--accent {
|
||||||
|
@apply !border-emerald-300/40 !bg-emerald-500/90 !text-white hover:!bg-emerald-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-btn--danger {
|
||||||
|
@apply !border-rose-300/40 !bg-rose-500/90 !text-white hover:!bg-rose-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-btn--primary {
|
||||||
|
@apply !border-amber-300/50 !bg-amber-400 !text-slate-900 hover:!bg-amber-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-btn--primary .dashboard-quick-action__label,
|
||||||
|
.dashboard-quick-action-btn--primary .dashboard-quick-action__icon {
|
||||||
|
@apply text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-link {
|
||||||
|
@apply no-underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-quick-action-link .dashboard-quick-action-btn {
|
||||||
|
@apply cursor-pointer transition-colors;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dashboard-operations-trigger"
|
||||||
|
:class="{ 'is-open': menuOpen }"
|
||||||
|
@click="menuOpen = true"
|
||||||
|
>
|
||||||
|
<BaseIcon icon="vuesax-linear:setting-2" :size="18" />
|
||||||
|
<span>عملیات</span>
|
||||||
|
<BaseIcon
|
||||||
|
icon="vuesax-linear:arrow-down"
|
||||||
|
:size="14"
|
||||||
|
class="dashboard-operations-trigger__chevron"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<BasicActionSheet
|
||||||
|
v-model:visible="menuOpen"
|
||||||
|
title="عملیات سریع"
|
||||||
|
compact
|
||||||
|
drawer-height="auto"
|
||||||
|
:show-button="false"
|
||||||
|
>
|
||||||
|
<div class="dash-quick-sheet">
|
||||||
|
<header class="dash-quick-sheet__header">
|
||||||
|
<h3 class="dash-quick-sheet__title">عملیات سریع</h3>
|
||||||
|
<p class="dash-quick-sheet__subtitle">دسترسی سریع به ابزارهای میز کار</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dash-quick-sheet__featured"
|
||||||
|
@click="runAction('wallet')"
|
||||||
|
>
|
||||||
|
<span class="dash-quick-sheet__featured-icon">
|
||||||
|
<BaseIcon icon="vuesax-linear:empty-wallet" :size="22" />
|
||||||
|
</span>
|
||||||
|
<span class="dash-quick-sheet__featured-text">
|
||||||
|
<span class="dash-quick-sheet__featured-label">شارژ / دشارژ کیف پول</span>
|
||||||
|
<span class="dash-quick-sheet__featured-hint">مدیریت موجودی ریالی و طلایی</span>
|
||||||
|
</span>
|
||||||
|
<BaseIcon icon="vuesax-linear:arrow-left-2" :size="16" class="dash-quick-sheet__featured-arrow" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<section v-for="group in mobileMenuGroups" :key="group.title" class="dash-quick-sheet__section">
|
||||||
|
<p class="dash-quick-sheet__section-title">{{ group.title }}</p>
|
||||||
|
<div class="dash-quick-sheet__list">
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="dash-quick-sheet__item"
|
||||||
|
:class="[
|
||||||
|
item.tone ? `dash-quick-sheet__item--${item.tone}` : '',
|
||||||
|
{ 'dash-quick-sheet__item--nav': item.nav },
|
||||||
|
]"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="runAction(item.key)"
|
||||||
|
>
|
||||||
|
<span class="dash-quick-sheet__badge" :class="`dash-quick-sheet__badge--${item.tone ?? 'slate'}`">
|
||||||
|
<BaseIcon :icon="item.icon" :size="18" />
|
||||||
|
</span>
|
||||||
|
<span class="dash-quick-sheet__item-body">
|
||||||
|
<span class="dash-quick-sheet__item-label">{{ item.label }}</span>
|
||||||
|
<span v-if="item.hint" class="dash-quick-sheet__item-hint">{{ item.hint }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="item.badge" class="dash-quick-sheet__count">{{ item.badge }}</span>
|
||||||
|
<BaseIcon
|
||||||
|
v-if="item.nav"
|
||||||
|
icon="vuesax-linear:arrow-left-2"
|
||||||
|
:size="15"
|
||||||
|
class="dash-quick-sheet__item-arrow"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</BasicActionSheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="flex max-w-full flex-wrap items-center justify-end gap-1.5 sm:justify-start">
|
||||||
|
<DashboardQuickActionBtn
|
||||||
|
v-for="item in actionItems"
|
||||||
|
:key="item.key"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.shortLabel"
|
||||||
|
:variant="item.variant"
|
||||||
|
:loading="item.loading"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
:to="item.to"
|
||||||
|
@click="item.onClick?.()"
|
||||||
|
>
|
||||||
|
<template v-if="item.key === 'restore'">
|
||||||
|
{{ item.shortLabel }}
|
||||||
|
<span v-if="productSnapshotCount" class="mr-0.5 opacity-80">({{ productSnapshotCount }})</span>
|
||||||
|
</template>
|
||||||
|
</DashboardQuickActionBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import BasicActionSheet from '@/components/BasicActionSheet.vue'
|
||||||
|
import DashboardQuickActionBtn from '@/pages/dashboard/components/DashboardQuickActionBtn.vue'
|
||||||
|
import { isMobile } from '@core/utils/is'
|
||||||
|
|
||||||
|
type ActionVariant = 'default' | 'accent' | 'danger' | 'primary'
|
||||||
|
type ItemTone = 'slate' | 'emerald' | 'rose' | 'amber' | 'sky' | 'violet'
|
||||||
|
|
||||||
|
type ActionItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
shortLabel: string
|
||||||
|
icon: string
|
||||||
|
variant?: ActionVariant
|
||||||
|
danger?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
to?: RouteLocationRaw
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileMenuItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
badge?: string
|
||||||
|
icon: string
|
||||||
|
tone?: ItemTone
|
||||||
|
disabled?: boolean
|
||||||
|
nav?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileMenuGroup = {
|
||||||
|
title: string
|
||||||
|
items: MobileMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
quickActionLoading: boolean
|
||||||
|
hasProductSnapshot: boolean
|
||||||
|
productSnapshotCount: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
wallet: []
|
||||||
|
deactivateAll: []
|
||||||
|
restoreProducts: []
|
||||||
|
closeAllBuy: []
|
||||||
|
closeAllSell: []
|
||||||
|
syncPrices: []
|
||||||
|
syncShow: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
|
const actionItems = computed<ActionItem[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'wallet',
|
||||||
|
label: 'شارژ / دشارژ کیف پول',
|
||||||
|
shortLabel: 'کیف پول',
|
||||||
|
icon: 'vuesax-linear:empty-wallet',
|
||||||
|
variant: 'accent',
|
||||||
|
onClick: () => emit('wallet'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deactivate-all',
|
||||||
|
label: 'بستن همه محصولات',
|
||||||
|
shortLabel: 'بستن همه محصولات',
|
||||||
|
icon: 'vuesax-linear:close-circle',
|
||||||
|
variant: 'danger',
|
||||||
|
danger: true,
|
||||||
|
loading: props.quickActionLoading,
|
||||||
|
onClick: () => emit('deactivateAll'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'restore',
|
||||||
|
label: 'بازگردانی محصولات قبلی',
|
||||||
|
shortLabel: 'بازگردانی محصولات',
|
||||||
|
icon: 'vuesax-linear:refresh-circle',
|
||||||
|
disabled: !props.hasProductSnapshot,
|
||||||
|
loading: props.quickActionLoading,
|
||||||
|
onClick: () => emit('restoreProducts'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-buy',
|
||||||
|
label: 'بستن خرید همه محصولات',
|
||||||
|
shortLabel: 'بستن خرید',
|
||||||
|
icon: 'vuesax-linear:money-receive',
|
||||||
|
loading: props.quickActionLoading,
|
||||||
|
onClick: () => emit('closeAllBuy'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-sell',
|
||||||
|
label: 'بستن فروش همه محصولات',
|
||||||
|
shortLabel: 'بستن فروش',
|
||||||
|
icon: 'vuesax-linear:money-send',
|
||||||
|
loading: props.quickActionLoading,
|
||||||
|
onClick: () => emit('closeAllSell'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sync-prices',
|
||||||
|
label: 'همگامسازی قیمت محصولات',
|
||||||
|
shortLabel: 'همگامسازی قیمت',
|
||||||
|
icon: 'vuesax-linear:coin',
|
||||||
|
loading: props.quickActionLoading,
|
||||||
|
onClick: () => emit('syncPrices'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sync-show',
|
||||||
|
label: 'بروزرسانی نمایش محصولات',
|
||||||
|
shortLabel: 'بروز نمایش',
|
||||||
|
icon: 'vuesax-linear:eye',
|
||||||
|
loading: props.quickActionLoading,
|
||||||
|
onClick: () => emit('syncShow'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'products',
|
||||||
|
label: 'مدیریت محصولات',
|
||||||
|
shortLabel: 'محصولات',
|
||||||
|
icon: 'vuesax-linear:box',
|
||||||
|
to: { name: 'productsMain_limitedOrder' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: 'لیست سفارشات',
|
||||||
|
shortLabel: 'سفارشات',
|
||||||
|
icon: 'vuesax-linear:receipt-2',
|
||||||
|
variant: 'primary',
|
||||||
|
to: { name: 'ordersMain' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const mobileMenuGroups = computed<MobileMenuGroup[]>(() => [
|
||||||
|
{
|
||||||
|
title: 'محصولات',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'deactivate-all',
|
||||||
|
label: 'بستن همه محصولات',
|
||||||
|
icon: 'vuesax-linear:close-circle',
|
||||||
|
tone: 'rose',
|
||||||
|
disabled: props.quickActionLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'restore',
|
||||||
|
label: 'بازگردانی محصولات',
|
||||||
|
hint: props.hasProductSnapshot ? 'بازگشت به وضعیت قبلی' : 'ابتدا محصولات را ببندید',
|
||||||
|
icon: 'vuesax-linear:refresh-circle',
|
||||||
|
tone: 'sky',
|
||||||
|
badge: props.productSnapshotCount ? String(props.productSnapshotCount) : undefined,
|
||||||
|
disabled: !props.hasProductSnapshot || props.quickActionLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-buy',
|
||||||
|
label: 'بستن خرید همه',
|
||||||
|
icon: 'vuesax-linear:money-receive',
|
||||||
|
tone: 'slate',
|
||||||
|
disabled: props.quickActionLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-sell',
|
||||||
|
label: 'بستن فروش همه',
|
||||||
|
icon: 'vuesax-linear:money-send',
|
||||||
|
tone: 'slate',
|
||||||
|
disabled: props.quickActionLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sync-prices',
|
||||||
|
label: 'همگامسازی قیمت',
|
||||||
|
icon: 'vuesax-linear:coin',
|
||||||
|
tone: 'amber',
|
||||||
|
disabled: props.quickActionLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sync-show',
|
||||||
|
label: 'بروزرسانی نمایش',
|
||||||
|
icon: 'vuesax-linear:eye',
|
||||||
|
tone: 'violet',
|
||||||
|
disabled: props.quickActionLoading,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'صفحات',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'products',
|
||||||
|
label: 'مدیریت محصولات',
|
||||||
|
icon: 'vuesax-linear:box',
|
||||||
|
tone: 'slate',
|
||||||
|
nav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: 'لیست سفارشات',
|
||||||
|
icon: 'vuesax-linear:receipt-2',
|
||||||
|
tone: 'amber',
|
||||||
|
nav: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const mobileActions: Record<string, () => void> = {
|
||||||
|
wallet: () => emit('wallet'),
|
||||||
|
'deactivate-all': () => emit('deactivateAll'),
|
||||||
|
restore: () => emit('restoreProducts'),
|
||||||
|
'close-buy': () => emit('closeAllBuy'),
|
||||||
|
'close-sell': () => emit('closeAllSell'),
|
||||||
|
'sync-prices': () => emit('syncPrices'),
|
||||||
|
'sync-show': () => emit('syncShow'),
|
||||||
|
products: () => void router.push({ name: 'productsMain_limitedOrder' }),
|
||||||
|
orders: () => void router.push({ name: 'ordersMain' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(key: string) {
|
||||||
|
menuOpen.value = false
|
||||||
|
mobileActions[key]?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-operations-trigger {
|
||||||
|
@apply inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-white/30 bg-white px-3.5 text-sm font-semibold text-slate-800 shadow-md transition-all;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-operations-trigger:hover {
|
||||||
|
@apply bg-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-operations-trigger.is-open {
|
||||||
|
@apply border-amber-300 bg-amber-50 text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-operations-trigger__chevron {
|
||||||
|
@apply opacity-60 transition-transform duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-operations-trigger.is-open .dashboard-operations-trigger__chevron {
|
||||||
|
@apply rotate-180 opacity-80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet {
|
||||||
|
@apply pb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__header {
|
||||||
|
@apply mb-4 px-1 text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__title {
|
||||||
|
@apply text-base font-bold text-slate-800 dark:text-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__subtitle {
|
||||||
|
@apply mt-0.5 text-xs text-slate-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__featured {
|
||||||
|
@apply mb-5 flex w-full items-center gap-3 rounded-2xl bg-gradient-to-l from-emerald-600 to-teal-600 p-3.5 text-right text-white shadow-lg shadow-emerald-900/15 transition-transform active:scale-[0.98];
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__featured-icon {
|
||||||
|
@apply flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white/20 backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__featured-text {
|
||||||
|
@apply flex min-w-0 flex-1 flex-col gap-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__featured-label {
|
||||||
|
@apply text-sm font-bold leading-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__featured-hint {
|
||||||
|
@apply text-[11px] leading-tight text-white/75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__featured-arrow {
|
||||||
|
@apply shrink-0 opacity-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__section + .dash-quick-sheet__section {
|
||||||
|
@apply mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__section-title {
|
||||||
|
@apply mb-2 px-1 text-[11px] font-semibold tracking-wide text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__list {
|
||||||
|
@apply overflow-hidden rounded-2xl border border-slate-100 bg-slate-50/80 dark:border-slate-700/60 dark:bg-slate-800/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item {
|
||||||
|
@apply flex w-full items-center gap-3 border-0 bg-transparent px-3 py-3 text-right transition-colors;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item + .dash-quick-sheet__item {
|
||||||
|
@apply border-t border-slate-100 dark:border-slate-700/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item:active:not(:disabled) {
|
||||||
|
@apply bg-white dark:bg-slate-700/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item:disabled {
|
||||||
|
@apply cursor-not-allowed opacity-45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge {
|
||||||
|
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge--slate {
|
||||||
|
@apply bg-slate-200/80 text-slate-600 dark:bg-slate-700 dark:text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge--emerald {
|
||||||
|
@apply bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge--rose {
|
||||||
|
@apply bg-rose-100 text-rose-600 dark:bg-rose-900/40 dark:text-rose-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge--amber {
|
||||||
|
@apply bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge--sky {
|
||||||
|
@apply bg-sky-100 text-sky-600 dark:bg-sky-900/40 dark:text-sky-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__badge--violet {
|
||||||
|
@apply bg-violet-100 text-violet-600 dark:bg-violet-900/40 dark:text-violet-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item-body {
|
||||||
|
@apply flex min-w-0 flex-1 flex-col gap-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item-label {
|
||||||
|
@apply text-[13px] font-semibold leading-tight text-slate-800 dark:text-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item-hint {
|
||||||
|
@apply text-[11px] leading-tight text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item--rose .dash-quick-sheet__item-label {
|
||||||
|
@apply text-rose-700 dark:text-rose-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__count {
|
||||||
|
@apply rounded-full bg-slate-200/90 px-2 py-0.5 text-[10px] font-bold text-slate-600 dark:bg-slate-600 dark:text-slate-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-quick-sheet__item-arrow {
|
||||||
|
@apply shrink-0 text-slate-300 dark:text-slate-500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<BasicActionSheet
|
||||||
|
v-model:visible="visibleProxy"
|
||||||
|
:title="modalTitle"
|
||||||
|
modal-width="22rem"
|
||||||
|
drawer-height="auto"
|
||||||
|
compact
|
||||||
|
:loading="loading"
|
||||||
|
:show-button="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="dashboard-wallet-modal">
|
||||||
|
<div class="dashboard-wallet-modal__section">
|
||||||
|
<label class="dashboard-wallet-modal__label">مشتری</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="customerId"
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
placeholder="انتخاب کنید"
|
||||||
|
class="dashboard-wallet-modal__select w-full"
|
||||||
|
size="large"
|
||||||
|
:options="customerOptions"
|
||||||
|
:filter-option="filterCustomer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedCustomer"
|
||||||
|
class="dashboard-wallet-modal__balances"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">طلایی</span>
|
||||||
|
<p class="font-semibold text-slate-800 dark:text-slate-100">{{ formatGold(selectedCustomer) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">ریالی</span>
|
||||||
|
<p class="font-semibold text-slate-800 dark:text-slate-100">{{ formatCash(selectedCustomer) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-wallet-modal__section">
|
||||||
|
<label class="dashboard-wallet-modal__label">نوع کیف پول</label>
|
||||||
|
<a-radio-group v-model:value="walletVariant" button-style="solid" class="flex w-full">
|
||||||
|
<a-radio-button value="money" class="w-1/2 text-center">ریالی</a-radio-button>
|
||||||
|
<a-radio-button value="gold" class="w-1/2 text-center">طلایی</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WalletChargeForm
|
||||||
|
ref="chargeFormRef"
|
||||||
|
class="dashboard-wallet-modal__form"
|
||||||
|
:model-value="formCharge"
|
||||||
|
:variant="walletVariant"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="dashboard-wallet-modal__footer">
|
||||||
|
<a-button danger size="large" block @click="closeModal">
|
||||||
|
بستن
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:danger="formCharge.direction === 'discharge'"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ okText }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BasicActionSheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import BasicActionSheet from '@/components/BasicActionSheet.vue'
|
||||||
|
import WalletChargeForm from '@/pages/menuPages/customers/base/components/WalletChargeForm.vue'
|
||||||
|
import {
|
||||||
|
buildChargePayload,
|
||||||
|
createEmptyChargeForm,
|
||||||
|
walletChargeOkText,
|
||||||
|
walletChargeSuccessMessage,
|
||||||
|
walletChargeTitle,
|
||||||
|
} from '@/pages/menuPages/customers/base/utils/walletChargeForm'
|
||||||
|
import { getCustomersList } from '@/api/customer'
|
||||||
|
import { chargeWallet } from '@/api/wallet'
|
||||||
|
import type { Customer } from '@/model/customer'
|
||||||
|
import type { Charge, Wallet } from '@/model/wallet'
|
||||||
|
import { usePrice } from '@core/utils/usePrice'
|
||||||
|
|
||||||
|
const props = defineProps<{ visible: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:visible', value: boolean): void }>()
|
||||||
|
|
||||||
|
const visibleProxy = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const customers = ref<Customer[]>([])
|
||||||
|
const walletVariant = ref<'money' | 'gold'>('money')
|
||||||
|
const formCharge = ref<Charge>(createEmptyChargeForm())
|
||||||
|
const chargeFormRef = ref<InstanceType<typeof WalletChargeForm>>()
|
||||||
|
|
||||||
|
const customerId = computed({
|
||||||
|
get: () => formCharge.value.customer_id ?? undefined,
|
||||||
|
set: (value: number | undefined) => {
|
||||||
|
formCharge.value.customer_id = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const customerOptions = computed(() =>
|
||||||
|
customers.value.map((customer) => ({
|
||||||
|
value: customer.id,
|
||||||
|
label: `${customer.name ?? ''} — ${customer.mobile ?? ''}`.trim(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCustomer = computed(() =>
|
||||||
|
customers.value.find((customer) => customer.id === formCharge.value.customer_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const modalTitle = computed(() =>
|
||||||
|
walletChargeTitle(walletVariant.value, formCharge.value.direction),
|
||||||
|
)
|
||||||
|
|
||||||
|
const okText = computed(() => walletChargeOkText(formCharge.value.direction))
|
||||||
|
|
||||||
|
function filterCustomer(input: string, option?: { label?: string }) {
|
||||||
|
return String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerWallet(customer: Customer): Wallet | undefined {
|
||||||
|
const wallet = customer.wallet as Wallet | Wallet[] | undefined
|
||||||
|
return Array.isArray(wallet) ? wallet[0] : wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGold(customer: Customer) {
|
||||||
|
const gold = customerWallet(customer)?.gold ?? customer.wallet_gold ?? customer.gold
|
||||||
|
return `${Number(gold ?? 0).toLocaleString('fa-IR', { maximumFractionDigits: 3 })} گرم`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCash(customer: Customer) {
|
||||||
|
const cash = customerWallet(customer)?.cash ?? customer.wallet_cash
|
||||||
|
return usePrice(Number(cash ?? 0), true, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
customers.value = (await getCustomersList()) ?? []
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
message.error('خطا در بارگذاری لیست مشتریان')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
walletVariant.value = 'money'
|
||||||
|
formCharge.value = createEmptyChargeForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
visibleProxy.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!formCharge.value.customer_id) {
|
||||||
|
message.warning('مشتری را انتخاب کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chargeFormRef.value?.validateFields?.()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await chargeWallet(buildChargePayload(formCharge.value), walletVariant.value)
|
||||||
|
message.success(walletChargeSuccessMessage(formCharge.value.direction))
|
||||||
|
closeModal()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
message.error('خطا در ثبت عملیات')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
void loadCustomers()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-wallet-modal {
|
||||||
|
@apply flex flex-col gap-4 px-4 pb-2 pt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__select :deep(.ant-select-selector) {
|
||||||
|
@apply !px-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__select :deep(.ant-select-selection-placeholder) {
|
||||||
|
@apply text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__section {
|
||||||
|
@apply flex flex-col gap-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__label {
|
||||||
|
@apply text-xs font-semibold text-slate-600 dark:text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__balances {
|
||||||
|
@apply grid grid-cols-2 gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs dark:border-slate-600 dark:bg-slate-800/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__footer {
|
||||||
|
@apply sticky bottom-0 grid grid-cols-2 gap-2 border-t border-slate-200 bg-white pt-3 dark:border-slate-600 dark:bg-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.ant-form-item) {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.ant-form-item-label > label) {
|
||||||
|
@apply text-xs font-semibold text-slate-600 dark:text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.wallet-charge-form__direction .ant-radio-button-wrapper) {
|
||||||
|
height: 2.5rem;
|
||||||
|
line-height: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.wallet-charge-form__preview) {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.wallet-charge-form__preview--charge) {
|
||||||
|
color: #166534;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.wallet-charge-form__preview--charge .wallet-charge-form__preview-sign) {
|
||||||
|
color: #15803d;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.wallet-charge-form__preview--discharge) {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-wallet-modal__form :deep(.wallet-charge-form__preview--discharge .wallet-charge-form__preview-sign) {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-2">
|
<div class="dashboard-section-header mb-3">
|
||||||
<DashboardLabelWithHelp
|
<DashboardLabelWithHelp
|
||||||
:label="title"
|
:label="title"
|
||||||
:help="help"
|
:help="help"
|
||||||
size="sm"
|
size="sm"
|
||||||
label-class="font-bold text-slate-900 dark:text-slate-100"
|
label-class="font-bold text-slate-900 dark:text-slate-100"
|
||||||
|
root-class="dashboard-section-title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const sizeClass = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.ios-info-badge {
|
.ios-info-badge {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: translateY(-0.5px);
|
transform: translateY(0);
|
||||||
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import moment from 'moment-jalaali'
|
import moment from 'moment-jalaali'
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
getDashboard,
|
getDashboard,
|
||||||
getDashboardBalance,
|
getDashboardBalance,
|
||||||
getDashboardFinance,
|
getDashboardFinance,
|
||||||
getDashboardFlow,
|
getDashboardFlow,
|
||||||
|
getDashboardOrderPeakHours,
|
||||||
getPriceChartData,
|
getPriceChartData,
|
||||||
getRecentOrders,
|
getRecentOrders,
|
||||||
toggleGateway,
|
toggleGateway,
|
||||||
@@ -15,21 +16,63 @@ import type {
|
|||||||
DashboardFlowPoint,
|
DashboardFlowPoint,
|
||||||
DashboardFinance,
|
DashboardFinance,
|
||||||
DashboardMaster,
|
DashboardMaster,
|
||||||
|
DashboardOrderPeakHours,
|
||||||
PriceChartPoint,
|
PriceChartPoint,
|
||||||
} from '@/model/dashboard'
|
} from '@/model/dashboard'
|
||||||
import type { Order } from '@/model/order'
|
import type { Order } from '@/model/order'
|
||||||
import type { Balance } from '@/model/product'
|
import type { Balance } from '@/model/product'
|
||||||
import { usePrice } from '@core/utils/usePrice'
|
import { usePrice } from '@core/utils/usePrice'
|
||||||
import { toJalali } from '@core/utils/useDataTime'
|
import { toJalali } from '@core/utils/useDataTime'
|
||||||
|
import { isMobile } from '@core/utils/is'
|
||||||
import { isStoreApp } from '@/utils/config'
|
import { isStoreApp } from '@/utils/config'
|
||||||
|
|
||||||
|
function truncateChartLabel(label: string, maxLen: number) {
|
||||||
|
const text = String(label ?? '').trim()
|
||||||
|
if (text.length <= maxLen) return text
|
||||||
|
return `${text.slice(0, Math.max(1, maxLen - 1))}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** عرض تقریبی ستون برچسب محصول در نمودار افقی */
|
||||||
|
function productChartLabelColumnWidth(labels: string[], mobile: boolean) {
|
||||||
|
const longest = labels.reduce((max, label) => Math.max(max, [...String(label)].length), 0)
|
||||||
|
const perChar = mobile ? 8.5 : 10
|
||||||
|
const min = mobile ? 80 : 112
|
||||||
|
const cap = mobile ? 118 : 240
|
||||||
|
return Math.min(cap, Math.max(min, Math.ceil(longest * perChar + 12)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeakHourLabel(hour: number) {
|
||||||
|
return `${hour.toLocaleString('fa-IR', { useGrouping: false })}:۰۰`
|
||||||
|
}
|
||||||
|
|
||||||
const MESGHAL = 4.331802
|
const MESGHAL = 4.331802
|
||||||
|
|
||||||
|
export type ProductActivityRange = 'day' | 'week' | 'month' | 'year'
|
||||||
|
|
||||||
|
function productActivityDateRange(range: ProductActivityRange) {
|
||||||
|
const end = moment().endOf('day')
|
||||||
|
const startByRange = {
|
||||||
|
day: moment().startOf('day'),
|
||||||
|
week: moment().subtract(7, 'days').startOf('day'),
|
||||||
|
month: moment().subtract(1, 'month').startOf('day'),
|
||||||
|
year: moment().subtract(1, 'year').startOf('day'),
|
||||||
|
}
|
||||||
|
const start = startByRange[range]
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDateProduct: start.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
endDateProduct: end.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useDashboardPage() {
|
export function useDashboardPage() {
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const flowLoading = ref(false)
|
const flowLoading = ref(false)
|
||||||
|
const productActivityLoading = ref(false)
|
||||||
const priceRange = ref<'DAILY' | 'WEEKLY' | 'MONTHLY'>('DAILY')
|
const priceRange = ref<'DAILY' | 'WEEKLY' | 'MONTHLY'>('DAILY')
|
||||||
const flowRange = ref<'week' | 'month'>('week')
|
const flowRange = ref<'week' | 'month'>('week')
|
||||||
|
const peakHoursRange = ref<7 | 30>(30)
|
||||||
|
const productActivityRange = ref<ProductActivityRange>('month')
|
||||||
|
|
||||||
const startDate = ref(moment().subtract(30, 'days').startOf('day').format('YYYY-MM-DD HH:mm:ss'))
|
const startDate = ref(moment().subtract(30, 'days').startOf('day').format('YYYY-MM-DD HH:mm:ss'))
|
||||||
const endDate = ref(moment().endOf('day').format('YYYY-MM-DD HH:mm:ss'))
|
const endDate = ref(moment().endOf('day').format('YYYY-MM-DD HH:mm:ss'))
|
||||||
@@ -40,12 +83,13 @@ export function useDashboardPage() {
|
|||||||
const walletSums = ref<{ gold?: number; cash?: number; goldToCash?: number; cashToCash?: number }>()
|
const walletSums = ref<{ gold?: number; cash?: number; goldToCash?: number; cashToCash?: number }>()
|
||||||
const recentOrders = ref<Order[]>([])
|
const recentOrders = ref<Order[]>([])
|
||||||
const flowData = ref<DashboardFlowPoint[]>([])
|
const flowData = ref<DashboardFlowPoint[]>([])
|
||||||
|
const peakHoursData = ref<DashboardOrderPeakHours | null>(null)
|
||||||
|
const peakHoursLoading = ref(false)
|
||||||
|
const priceChartByRange = ref<Partial<Record<'DAILY' | 'WEEKLY' | 'MONTHLY', PriceChartPoint[]>>>({})
|
||||||
const priceSeries = ref<PriceChartPoint[]>([])
|
const priceSeries = ref<PriceChartPoint[]>([])
|
||||||
|
|
||||||
const isStore = computed(() => isStoreApp())
|
const isStore = computed(() => isStoreApp())
|
||||||
|
|
||||||
const pendingOrders = computed(() => master.value?.await_order ?? [])
|
|
||||||
|
|
||||||
const customerStats = computed(() => ({
|
const customerStats = computed(() => ({
|
||||||
newCount: isStore.value
|
newCount: isStore.value
|
||||||
? master.value?.new_customer_major ?? 0
|
? master.value?.new_customer_major ?? 0
|
||||||
@@ -60,11 +104,11 @@ export function useDashboardPage() {
|
|||||||
|
|
||||||
async function loadCore() {
|
async function loadCore() {
|
||||||
const [dash, fin, bal, wallet, orders, prices] = await Promise.allSettled([
|
const [dash, fin, bal, wallet, orders, prices] = await Promise.allSettled([
|
||||||
getDashboard(),
|
getDashboard(productActivityDateRange(productActivityRange.value)),
|
||||||
getDashboardFinance(startDate.value, endDate.value),
|
getDashboardFinance(startDate.value, endDate.value),
|
||||||
getDashboardBalance(startDate.value, endDate.value),
|
getDashboardBalance(startDate.value, endDate.value),
|
||||||
getWalletTable({ limit: 1, page: 1 } as never),
|
getWalletTable({ limit: 1, page: 1 } as never),
|
||||||
getRecentOrders(12),
|
getRecentOrders(14),
|
||||||
getPriceChartData(),
|
getPriceChartData(),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -74,10 +118,21 @@ export function useDashboardPage() {
|
|||||||
if (wallet.status === 'fulfilled') walletSums.value = wallet.value?.sums
|
if (wallet.status === 'fulfilled') walletSums.value = wallet.value?.sums
|
||||||
if (orders.status === 'fulfilled') recentOrders.value = orders.value
|
if (orders.status === 'fulfilled') recentOrders.value = orders.value
|
||||||
if (prices.status === 'fulfilled') {
|
if (prices.status === 'fulfilled') {
|
||||||
priceSeries.value = prices.value?.DAILY ?? []
|
priceChartByRange.value = prices.value ?? {}
|
||||||
|
applyPriceRangeSeries(priceRange.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chartPointRial(value?: number) {
|
||||||
|
const scaled = Number(value ?? 0)
|
||||||
|
if (scaled <= 0) return 0
|
||||||
|
return (scaled - 1) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceRangeSeries(range: 'DAILY' | 'WEEKLY' | 'MONTHLY') {
|
||||||
|
priceSeries.value = priceChartByRange.value[range] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFlow() {
|
async function loadFlow() {
|
||||||
flowLoading.value = true
|
flowLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -90,10 +145,41 @@ export function useDashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPeakHours() {
|
||||||
|
peakHoursLoading.value = true
|
||||||
|
try {
|
||||||
|
peakHoursData.value = (await getDashboardOrderPeakHours(peakHoursRange.value)) ?? null
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
peakHoursData.value = null
|
||||||
|
} finally {
|
||||||
|
peakHoursLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProductActivity() {
|
||||||
|
productActivityLoading.value = true
|
||||||
|
try {
|
||||||
|
const dash = await getDashboard(productActivityDateRange(productActivityRange.value))
|
||||||
|
if (master.value) {
|
||||||
|
master.value = {
|
||||||
|
...master.value,
|
||||||
|
GT_products_report: dash?.GT_products_report ?? [],
|
||||||
|
}
|
||||||
|
} else if (dash) {
|
||||||
|
master.value = dash
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
productActivityLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadCore(), loadFlow()])
|
await Promise.all([loadCore(), loadFlow(), loadPeakHours()])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -113,13 +199,29 @@ export function useDashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchPriceRange(range?: 'DAILY' | 'WEEKLY' | 'MONTHLY') {
|
function switchPriceRange(range?: 'DAILY' | 'WEEKLY' | 'MONTHLY') {
|
||||||
const key = range ?? priceRange.value
|
if (range) {
|
||||||
priceRange.value = key
|
priceRange.value = range
|
||||||
const prices = await getPriceChartData()
|
}
|
||||||
priceSeries.value = prices?.[key] ?? []
|
applyPriceRangeSeries(priceRange.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(priceRange, (key) => {
|
||||||
|
applyPriceRangeSeries(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(productActivityRange, () => {
|
||||||
|
void loadProductActivity()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(flowRange, () => {
|
||||||
|
void loadFlow()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(peakHoursRange, () => {
|
||||||
|
void loadPeakHours()
|
||||||
|
})
|
||||||
|
|
||||||
async function onToggleGateway() {
|
async function onToggleGateway() {
|
||||||
await toggleGateway()
|
await toggleGateway()
|
||||||
master.value = await getDashboard()
|
master.value = await getDashboard()
|
||||||
@@ -160,10 +262,16 @@ export function useDashboardPage() {
|
|||||||
const gifts = finance.value?.GiftsPrise ?? 0
|
const gifts = finance.value?.GiftsPrise ?? 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chart: { type: 'donut', fontFamily: 'inherit' },
|
chart: { type: 'donut', fontFamily: 'inherit', offsetY: 4 },
|
||||||
labels: ['سود معامله', 'کارمزد طلایی', 'هدایای طلا'],
|
labels: ['سود معامله', 'کارمزد طلایی', 'هدایای طلا'],
|
||||||
colors: ['#10b981', '#f59e0b', '#8b5cf6'],
|
colors: ['#10b981', '#f59e0b', '#8b5cf6'],
|
||||||
legend: { position: 'bottom', fontSize: '12px' },
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '12px',
|
||||||
|
horizontalAlign: 'center',
|
||||||
|
offsetY: 6,
|
||||||
|
itemMargin: { horizontal: 12, vertical: 4 },
|
||||||
|
},
|
||||||
dataLabels: { enabled: false },
|
dataLabels: { enabled: false },
|
||||||
plotOptions: {
|
plotOptions: {
|
||||||
pie: {
|
pie: {
|
||||||
@@ -188,6 +296,7 @@ export function useDashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const flowChartOptions = computed(() => {
|
const flowChartOptions = computed(() => {
|
||||||
|
const mobile = isMobile.value
|
||||||
const points = [...flowData.value].sort((a, b) =>
|
const points = [...flowData.value].sort((a, b) =>
|
||||||
String(a.date ?? '').localeCompare(String(b.date ?? '')),
|
String(a.date ?? '').localeCompare(String(b.date ?? '')),
|
||||||
)
|
)
|
||||||
@@ -197,16 +306,39 @@ export function useDashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chart: { type: 'bar', stacked: true, toolbar: { show: false }, fontFamily: 'inherit' },
|
chart: {
|
||||||
plotOptions: { bar: { borderRadius: 4, columnWidth: '55%' } },
|
type: 'bar',
|
||||||
|
stacked: true,
|
||||||
|
toolbar: { show: false },
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
redrawOnParentResize: true,
|
||||||
|
},
|
||||||
|
plotOptions: { bar: { borderRadius: 4, columnWidth: mobile ? '48%' : '55%' } },
|
||||||
colors: ['#10b981', '#f43f5e'],
|
colors: ['#10b981', '#f43f5e'],
|
||||||
xaxis: { categories, labels: { rotate: -35, style: { fontSize: '11px' } } },
|
grid: {
|
||||||
yaxis: {
|
padding: { left: mobile ? 0 : 4, right: mobile ? 4 : 8 },
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories,
|
||||||
labels: {
|
labels: {
|
||||||
formatter: (v: number) => `${Number(v).toLocaleString('fa-IR')} گرم`,
|
rotate: mobile ? -45 : -35,
|
||||||
|
style: { fontSize: mobile ? '10px' : '11px' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { position: 'top' },
|
yaxis: {
|
||||||
|
tickAmount: mobile ? 4 : 6,
|
||||||
|
labels: {
|
||||||
|
formatter: (v: number) =>
|
||||||
|
mobile ? Number(v).toLocaleString('fa-IR') : `${Number(v).toLocaleString('fa-IR')} گرم`,
|
||||||
|
style: { fontSize: mobile ? '10px' : '11px' },
|
||||||
|
maxWidth: mobile ? 40 : 64,
|
||||||
|
offsetX: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
y: { formatter: (v: number) => `${Number(v).toLocaleString('fa-IR')} گرم` },
|
||||||
|
},
|
||||||
|
legend: { position: 'top', fontSize: mobile ? '10px' : '11px' },
|
||||||
dataLabels: { enabled: false },
|
dataLabels: { enabled: false },
|
||||||
series: [
|
series: [
|
||||||
{ name: 'خرید مشتری', data: points.map((p) => Number(p[1] ?? p['1'] ?? 0)) },
|
{ name: 'خرید مشتری', data: points.map((p) => Number(p[1] ?? p['1'] ?? 0)) },
|
||||||
@@ -215,48 +347,197 @@ export function useDashboardPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const priceChartOptions = computed(() => ({
|
const priceChartOptions = computed(() => {
|
||||||
chart: { type: 'area', toolbar: { show: false }, fontFamily: 'inherit', zoom: { enabled: false } },
|
const tooltipDateFormat =
|
||||||
stroke: { curve: 'smooth', width: 2 },
|
priceRange.value === 'DAILY' ? 'yyyy/MM/dd HH:mm' : 'yyyy/MM/dd'
|
||||||
fill: {
|
|
||||||
type: 'gradient',
|
|
||||||
gradient: { shadeIntensity: 1, opacityFrom: 0.45, opacityTo: 0.05 },
|
|
||||||
},
|
|
||||||
colors: ['#2563eb'],
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
xaxis: {
|
|
||||||
type: 'datetime',
|
|
||||||
labels: { datetimeUTC: false, style: { fontSize: '11px' } },
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: {
|
|
||||||
formatter: (v: number) => `${Math.round(v / 1_000_000).toLocaleString('fa-IR')}M`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
x: { format: 'yyyy/MM/dd HH:mm' },
|
|
||||||
y: { formatter: (v: number) => usePrice(v, true, '') },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'مظنه',
|
|
||||||
data: priceSeries.value.map((p) => [p.timestamp, p.value]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const productChartOptions = computed(() => {
|
|
||||||
const items = (master.value?.GT_products_report ?? []).slice(0, 8)
|
|
||||||
return {
|
return {
|
||||||
chart: { type: 'bar', toolbar: { show: false }, fontFamily: 'inherit' },
|
chart: { type: 'area', toolbar: { show: false }, fontFamily: 'inherit', zoom: { enabled: false } },
|
||||||
plotOptions: { bar: { horizontal: true, borderRadius: 4 } },
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
colors: ['#0ea5e9', '#f97316'],
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: { shadeIntensity: 1, opacityFrom: 0.45, opacityTo: 0.05 },
|
||||||
|
},
|
||||||
|
colors: ['#2563eb'],
|
||||||
dataLabels: { enabled: false },
|
dataLabels: { enabled: false },
|
||||||
xaxis: {
|
xaxis: {
|
||||||
categories: items.map((p) => p.title ?? `#${p.id}`),
|
type: 'datetime',
|
||||||
labels: { style: { fontSize: '11px' } },
|
labels: {
|
||||||
|
datetimeUTC: false,
|
||||||
|
style: { fontSize: '11px' },
|
||||||
|
datetimeFormatter: {
|
||||||
|
year: 'yyyy',
|
||||||
|
month: 'MMM',
|
||||||
|
day: priceRange.value === 'DAILY' ? 'dd HH:mm' : 'dd MMM',
|
||||||
|
hour: 'HH:mm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
formatter: (v: number) => `${Math.round(v / 1_000_000).toLocaleString('fa-IR')}M`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
x: { format: tooltipDateFormat },
|
||||||
|
y: { formatter: (v: number) => usePrice(v, true, '') },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'مظنه',
|
||||||
|
data: priceSeries.value.map((p) => [p.timestamp, chartPointRial(p.value)]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const flowChartHeight = computed(() => (isMobile.value ? 240 : 220))
|
||||||
|
|
||||||
|
const peakHourSlots = computed(() => peakHoursData.value?.hours ?? [])
|
||||||
|
|
||||||
|
const topPeakHours = computed(() =>
|
||||||
|
[...peakHourSlots.value]
|
||||||
|
.filter((slot) => slot.total > 0)
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
const peakHoursChartOptions = computed(() => {
|
||||||
|
const mobile = isMobile.value
|
||||||
|
const slots = peakHourSlots.value
|
||||||
|
const categories = slots.map((slot) => formatPeakHourLabel(slot.hour))
|
||||||
|
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: true,
|
||||||
|
toolbar: { show: false },
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
redrawOnParentResize: true,
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
borderRadius: 3,
|
||||||
|
columnWidth: mobile ? '72%' : '62%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#10b981', '#f43f5e'],
|
||||||
|
grid: {
|
||||||
|
padding: { left: mobile ? 0 : 4, right: mobile ? 4 : 8 },
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories,
|
||||||
|
tickAmount: mobile ? 8 : 12,
|
||||||
|
labels: {
|
||||||
|
rotate: mobile ? -55 : -35,
|
||||||
|
style: { fontSize: mobile ? '9px' : '10px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
tickAmount: mobile ? 4 : 5,
|
||||||
|
labels: {
|
||||||
|
formatter: (v: number) => Number(v).toLocaleString('fa-IR'),
|
||||||
|
style: { fontSize: mobile ? '10px' : '11px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
y: { formatter: (v: number) => `${Number(v).toLocaleString('fa-IR')} سفارش` },
|
||||||
|
},
|
||||||
|
legend: { position: 'top', fontSize: mobile ? '10px' : '11px' },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
series: [
|
||||||
|
{ name: 'خرید', data: slots.map((slot) => slot.buy) },
|
||||||
|
{ name: 'فروش', data: slots.map((slot) => slot.sell) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const peakHoursChartHeight = computed(() => (isMobile.value ? 250 : 230))
|
||||||
|
|
||||||
|
const productReportItems = computed(() => master.value?.GT_products_report ?? [])
|
||||||
|
|
||||||
|
const productChartHeight = computed(() => {
|
||||||
|
const count = Math.max(productReportItems.value.length, 1)
|
||||||
|
const rowHeight = isMobile.value ? 74 : 58
|
||||||
|
return Math.max(isMobile.value ? 210 : 230, count * rowHeight + 52)
|
||||||
|
})
|
||||||
|
|
||||||
|
const productChartLabelWidth = computed(() => {
|
||||||
|
const items = productReportItems.value.slice(0, 8)
|
||||||
|
const mobile = isMobile.value
|
||||||
|
const categories = items.map((p) => p.title ?? `#${p.id}`)
|
||||||
|
const displayLabels = categories.map((label) =>
|
||||||
|
mobile ? truncateChartLabel(label, 11) : label,
|
||||||
|
)
|
||||||
|
return productChartLabelColumnWidth(displayLabels, mobile)
|
||||||
|
})
|
||||||
|
|
||||||
|
const productChartOptions = computed(() => {
|
||||||
|
const items = productReportItems.value.slice(0, 8)
|
||||||
|
const mobile = isMobile.value
|
||||||
|
const categories = items.map((p) => p.title ?? `#${p.id}`)
|
||||||
|
const labelWidth = productChartLabelWidth.value
|
||||||
|
const displayLabel = (val: string) =>
|
||||||
|
mobile ? truncateChartLabel(String(val), 11) : String(val)
|
||||||
|
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
toolbar: { show: false },
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
redrawOnParentResize: true,
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
borderRadius: 4,
|
||||||
|
barHeight: mobile ? '58%' : '68%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#0ea5e9', '#f97316'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
left: 0,
|
||||||
|
right: 10,
|
||||||
|
top: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories,
|
||||||
|
labels: { style: { fontSize: mobile ? '10px' : '11px' } },
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
minWidth: labelWidth,
|
||||||
|
maxWidth: labelWidth,
|
||||||
|
align: 'left',
|
||||||
|
offsetX: mobile ? -4 : -6,
|
||||||
|
style: {
|
||||||
|
fontSize: mobile ? '10px' : '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
formatter: displayLabel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
y: {
|
||||||
|
formatter: (val: number) => `${Number(val).toLocaleString('fa-IR')} گرم`,
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
formatter: (_val: number, opts?: { dataPointIndex?: number }) =>
|
||||||
|
categories[opts?.dataPointIndex ?? 0] ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'center',
|
||||||
|
fontSize: mobile ? '10px' : '11px',
|
||||||
},
|
},
|
||||||
legend: { position: 'top' },
|
|
||||||
series: [
|
series: [
|
||||||
{ name: 'خرید (گرم)', data: items.map((p) => p.transaction_sum_gold_buy ?? 0) },
|
{ name: 'خرید (گرم)', data: items.map((p) => p.transaction_sum_gold_buy ?? 0) },
|
||||||
{
|
{
|
||||||
@@ -281,18 +562,28 @@ export function useDashboardPage() {
|
|||||||
balance,
|
balance,
|
||||||
walletSums,
|
walletSums,
|
||||||
recentOrders,
|
recentOrders,
|
||||||
pendingOrders,
|
|
||||||
customerStats,
|
customerStats,
|
||||||
priceRange,
|
priceRange,
|
||||||
flowRange,
|
flowRange,
|
||||||
|
peakHoursRange,
|
||||||
|
peakHoursData,
|
||||||
|
peakHoursLoading,
|
||||||
|
topPeakHours,
|
||||||
flowData,
|
flowData,
|
||||||
priceSeries,
|
priceSeries,
|
||||||
isStore,
|
isStore,
|
||||||
MESGHAL,
|
MESGHAL,
|
||||||
|
productActivityRange,
|
||||||
|
productActivityLoading,
|
||||||
|
productReportItems,
|
||||||
|
productChartHeight,
|
||||||
|
productChartLabelWidth,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
applyDateRange,
|
applyDateRange,
|
||||||
switchPriceRange,
|
switchPriceRange,
|
||||||
loadFlow,
|
loadFlow,
|
||||||
|
loadPeakHours,
|
||||||
|
loadProductActivity,
|
||||||
onToggleGateway,
|
onToggleGateway,
|
||||||
formatGram,
|
formatGram,
|
||||||
directionLabel,
|
directionLabel,
|
||||||
@@ -301,6 +592,10 @@ export function useDashboardPage() {
|
|||||||
stsLabel,
|
stsLabel,
|
||||||
financeChartOptions,
|
financeChartOptions,
|
||||||
flowChartOptions,
|
flowChartOptions,
|
||||||
|
flowChartHeight,
|
||||||
|
peakHoursChartOptions,
|
||||||
|
peakHoursChartHeight,
|
||||||
|
formatPeakHourLabel,
|
||||||
priceChartOptions,
|
priceChartOptions,
|
||||||
productChartOptions,
|
productChartOptions,
|
||||||
usePrice,
|
usePrice,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { Modal, message } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
closeAllProductBuy,
|
||||||
|
closeAllProductSell,
|
||||||
|
deactivateAllProducts,
|
||||||
|
getProductActivationSnapshot,
|
||||||
|
restoreProductActivation,
|
||||||
|
syncProductShow,
|
||||||
|
} from '@/api/product'
|
||||||
|
import { runPriceSync } from '@/api/price'
|
||||||
|
|
||||||
|
export function useDashboardQuickActions(onAfterAction?: () => Promise<void>) {
|
||||||
|
const quickActionLoading = ref(false)
|
||||||
|
const hasProductSnapshot = ref(false)
|
||||||
|
const productSnapshotCount = ref(0)
|
||||||
|
|
||||||
|
async function loadSnapshotStatus() {
|
||||||
|
try {
|
||||||
|
const res = await getProductActivationSnapshot()
|
||||||
|
hasProductSnapshot.value = !!res?.has_snapshot
|
||||||
|
productSnapshotCount.value = res?.count ?? 0
|
||||||
|
} catch {
|
||||||
|
hasProductSnapshot.value = false
|
||||||
|
productSnapshotCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQuickAction(action: () => Promise<{ message?: string } | void>) {
|
||||||
|
quickActionLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await action()
|
||||||
|
message.success(res?.message ?? 'انجام شد')
|
||||||
|
await loadSnapshotStatus()
|
||||||
|
await onAfterAction?.()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
message.error('خطا در انجام عملیات')
|
||||||
|
} finally {
|
||||||
|
quickActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmQuickAction(title: string, content: string, action: () => Promise<{ message?: string }>) {
|
||||||
|
Modal.confirm({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
okText: 'بله',
|
||||||
|
cancelText: 'انصراف',
|
||||||
|
centered: true,
|
||||||
|
okButtonProps: { danger: title.includes('بستن') && title.includes('محصول') },
|
||||||
|
onOk: () => runQuickAction(action),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateAll() {
|
||||||
|
confirmQuickAction(
|
||||||
|
'بستن همه محصولات',
|
||||||
|
'همه محصولات (خرید، فروش و نمایش) بسته میشوند. وضعیت فعلی ذخیره میشود تا بتوانید بعداً بازگردانی کنید.',
|
||||||
|
deactivateAllProducts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProducts() {
|
||||||
|
confirmQuickAction(
|
||||||
|
'بازگردانی محصولات',
|
||||||
|
`وضعیت ${productSnapshotCount.value || ''} محصول ذخیرهشده بازگردانی میشود.`,
|
||||||
|
restoreProductActivation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllBuy() {
|
||||||
|
confirmQuickAction(
|
||||||
|
'بستن خرید همه محصولات',
|
||||||
|
'امکان خرید برای همه محصولات غیرفعال میشود.',
|
||||||
|
closeAllProductBuy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllSell() {
|
||||||
|
confirmQuickAction(
|
||||||
|
'بستن فروش همه محصولات',
|
||||||
|
'امکان فروش برای همه محصولات غیرفعال میشود.',
|
||||||
|
closeAllProductSell,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncShow() {
|
||||||
|
void runQuickAction(syncProductShow)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPrices() {
|
||||||
|
confirmQuickAction(
|
||||||
|
'همگامسازی قیمت',
|
||||||
|
'قیمت محصولات از منبع تنظیمشده بهروزرسانی میشود.',
|
||||||
|
async () => {
|
||||||
|
const res = await runPriceSync()
|
||||||
|
return { message: res?.message ?? (res?.data ? 'قیمت بهروز شد' : 'قیمت تغییری نکرد') }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quickActionLoading,
|
||||||
|
hasProductSnapshot,
|
||||||
|
productSnapshotCount,
|
||||||
|
loadSnapshotStatus,
|
||||||
|
deactivateAll,
|
||||||
|
restoreProducts,
|
||||||
|
closeAllBuy,
|
||||||
|
closeAllSell,
|
||||||
|
syncShow,
|
||||||
|
syncPrices,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,18 +78,18 @@ export const dashboardCharts = {
|
|||||||
formula: 'نمودار = قیمت پایه در هر لحظه',
|
formula: 'نمودار = قیمت پایه در هر لحظه',
|
||||||
},
|
},
|
||||||
productActivity: {
|
productActivity: {
|
||||||
description: 'مقایسه حجم خرید و فروش هر محصول.',
|
description: 'مقایسه حجم خرید و فروش هر محصول در بازه انتخابی.',
|
||||||
formula: 'خرید = جمع گرم مثبت | فروش = جمع گرم منفی',
|
formula: 'خرید = جمع گرم مثبت | فروش = جمع گرم منفی (معاملات همان بازه)',
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, DashboardHelp>
|
} as const satisfies Record<string, DashboardHelp>
|
||||||
|
|
||||||
export const dashboardOrders = {
|
export const dashboardOrders = {
|
||||||
pending: {
|
peakHours: {
|
||||||
description: 'سفارشهایی که هنوز تأیید نشدهاند.',
|
description: 'توزیع سفارشهای موفق مشتریان خانگی در ۲۴ ساعت شبانهروز؛ برای شناخت ساعات شلوغ.',
|
||||||
formula: 'سود هر گرم × مقدار = سود کل سفارش',
|
formula: 'هر ستون = تعداد سفارش در آن ساعت (جمع چند روز اخیر)',
|
||||||
},
|
},
|
||||||
recent: {
|
recent: {
|
||||||
description: '۱۲ سفارش آخر با جزئیات مظنه و سود.',
|
description: '۱۴ سفارش آخر با جزئیات مظنه و سود.',
|
||||||
formula: 'مظنه = قیمت پایه | قیمت مشتری = مظنه ± اسپرد | سود/گرم = تفاوت اسپرد',
|
formula: 'مظنه = قیمت پایه | قیمت مشتری = مظنه ± اسپرد | سود/گرم = تفاوت اسپرد',
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, DashboardHelp>
|
} as const satisfies Record<string, DashboardHelp>
|
||||||
|
|||||||
+282
-128
@@ -3,47 +3,48 @@
|
|||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section
|
<section
|
||||||
class="rounded-xl bg-gradient-to-l from-[#0f766e] via-[#1e3a5f] to-[#0f172a] px-3 py-2 text-white shadow-md"
|
class="dashboard-hero rounded-xl bg-gradient-to-l from-[#0f766e] via-[#1e3a5f] to-[#0f172a] px-3 py-2.5 text-white shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5">
|
<div class="flex flex-col gap-2.5 md:flex-row md:items-center md:justify-between md:gap-3">
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
|
<div class="order-2 min-w-0 md:order-1">
|
||||||
<DashboardLabelWithHelp
|
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
label="مظنه"
|
|
||||||
:help="copy.hero"
|
|
||||||
size="xs"
|
|
||||||
text-class="text-[11px]"
|
|
||||||
label-class="text-white/80"
|
|
||||||
/>
|
|
||||||
<span class="text-[11px] text-white/80">/گرم</span>
|
|
||||||
<span class="text-lg font-bold leading-none">{{ usePrice(master?.price ?? 0, true, '') }}</span>
|
|
||||||
<span class="text-white/35">·</span>
|
|
||||||
<span class="text-[11px] text-white/80">مثقال</span>
|
|
||||||
<span class="text-sm font-semibold leading-none">{{ usePrice(master?.pricem ?? 0, true, '') }}</span>
|
|
||||||
<span v-if="master?.update_date" class="text-[10px] text-white/55">
|
|
||||||
· {{ toJalali(master.update_date) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
||||||
<div class="flex items-center gap-1.5 leading-none rounded-lg bg-white/10 px-2 py-1 text-[11px]">
|
|
||||||
<BaseIcon icon="vuesax-linear:card" size="14" class="text-white/90" />
|
|
||||||
<DashboardLabelWithHelp
|
<DashboardLabelWithHelp
|
||||||
label="واریز آنلاین"
|
label="مظنه"
|
||||||
:help="copy.gateway"
|
:help="copy.hero"
|
||||||
size="xs"
|
size="xs"
|
||||||
text-class="text-[11px]"
|
text-class="text-[11px]"
|
||||||
label-class="text-white/85"
|
label-class="text-white/80"
|
||||||
/>
|
/>
|
||||||
<span class="font-semibold">{{ master?.is_gateway_open ? 'فعال' : 'غیرفعال' }}</span>
|
<span class="text-[11px] text-white/80">/گرم</span>
|
||||||
<a-switch size="small" :checked="master?.is_gateway_open" @change="onToggleGateway" />
|
<span class="text-lg font-bold leading-none">{{ usePrice(master?.price ?? 0, true, '') }}</span>
|
||||||
|
<span class="text-white/35">·</span>
|
||||||
|
<span class="text-[11px] text-white/80">مثقال</span>
|
||||||
|
<span class="text-sm font-semibold leading-none">{{ usePrice(master?.pricem ?? 0, true, '') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<router-link :to="{ name: 'ordersMain' }">
|
<p v-if="master?.update_date" class="mt-1 text-[10px] leading-none text-white/55">
|
||||||
<a-button type="primary" size="small" class="!h-7 !rounded-lg !px-3">سفارشات</a-button>
|
{{ toJalali(master.update_date) }}
|
||||||
</router-link>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-1 w-full shrink-0 md:order-2 md:w-auto">
|
||||||
|
<DashboardQuickActionsBar
|
||||||
|
:quick-action-loading="quickActionLoading"
|
||||||
|
:has-product-snapshot="hasProductSnapshot"
|
||||||
|
:product-snapshot-count="productSnapshotCount"
|
||||||
|
@wallet="showQuickWallet = true"
|
||||||
|
@deactivate-all="deactivateAll"
|
||||||
|
@restore-products="restoreProducts"
|
||||||
|
@close-all-buy="closeAllBuy"
|
||||||
|
@close-all-sell="closeAllSell"
|
||||||
|
@sync-prices="syncPrices"
|
||||||
|
@sync-show="syncShow"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DashboardQuickWalletModal v-model:visible="showQuickWallet" />
|
||||||
|
|
||||||
<!-- KPI -->
|
<!-- KPI -->
|
||||||
<section class="mt-2 grid grid-cols-2 items-stretch gap-2 md:grid-cols-3 xl:grid-cols-6">
|
<section class="mt-2 grid grid-cols-2 items-stretch gap-2 md:grid-cols-3 xl:grid-cols-6">
|
||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
@@ -111,19 +112,32 @@
|
|||||||
<p class="text-sm font-bold text-slate-900 dark:text-slate-50">{{ metric.value }}</p>
|
<p class="text-sm font-bold text-slate-900 dark:text-slate-50">{{ metric.value }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-end gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<BaseDatePicker v-model:value="startDate" format="YYYY-MM-DD HH:mm:ss" class="!w-44" />
|
<BaseDatePicker
|
||||||
<span class="pb-1 text-xs text-slate-500 dark:text-slate-400">تا</span>
|
v-model:value="startDate"
|
||||||
<BaseDatePicker v-model:value="endDate" format="YYYY-MM-DD HH:mm:ss" class="!w-44" />
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
<a-button type="primary" size="small" @click="applyDateRange">اعمال</a-button>
|
class="!w-44"
|
||||||
<a-button size="small" @click="refreshAll">بروزرسانی</a-button>
|
size="small"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-slate-500 dark:text-slate-400">تا</span>
|
||||||
|
<BaseDatePicker
|
||||||
|
v-model:value="endDate"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
class="!w-44"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a-button type="primary" size="small" @click="applyDateRange">اعمال</a-button>
|
||||||
|
<a-button size="small" @click="refreshAll">بروزرسانی</a-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Charts row 1 -->
|
<!-- Charts row 1 -->
|
||||||
<section class="mt-2 grid grid-cols-1 gap-2 xl:grid-cols-2">
|
<section class="mt-2 grid grid-cols-1 gap-2 xl:grid-cols-2">
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900">
|
<div class="dashboard-panel">
|
||||||
<DashboardSectionHeader title="ترکیب درآمد" :help="copy.charts.incomeMix" />
|
<DashboardSectionHeader title="ترکیب درآمد" :help="copy.charts.incomeMix" />
|
||||||
|
<div class="dashboard-chart-wrap">
|
||||||
<apexchart
|
<apexchart
|
||||||
v-if="(finance?.fullPriceSood ?? 0) + (finance?.fullPriceKarmozd ?? 0) > 0"
|
v-if="(finance?.fullPriceSood ?? 0) + (finance?.fullPriceKarmozd ?? 0) > 0"
|
||||||
type="donut"
|
type="donut"
|
||||||
@@ -137,31 +151,31 @@
|
|||||||
>
|
>
|
||||||
در این بازه داده مالی ثبت نشده
|
در این بازه داده مالی ثبت نشده
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900">
|
<div class="dashboard-panel">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
class="!mb-0 min-w-0 flex-1"
|
class="!mb-0 min-w-0 flex-1"
|
||||||
title="حجم خرید و فروش"
|
title="حجم خرید و فروش"
|
||||||
:help="copy.charts.buySellVolume"
|
:help="copy.charts.buySellVolume"
|
||||||
/>
|
/>
|
||||||
<a-segmented
|
<a-radio-group
|
||||||
v-model:value="flowRange"
|
v-model:value="flowRange"
|
||||||
|
button-style="solid"
|
||||||
size="small"
|
size="small"
|
||||||
class="mt-0.5 shrink-0"
|
class="shrink-0"
|
||||||
:options="[
|
>
|
||||||
{ label: '۷ روز', value: 'week' },
|
<a-radio-button value="week">۷ روز</a-radio-button>
|
||||||
{ label: '۳۰ روز', value: 'month' },
|
<a-radio-button value="month">۳۰ روز</a-radio-button>
|
||||||
]"
|
</a-radio-group>
|
||||||
@change="loadFlow"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<a-spin :spinning="flowLoading">
|
<a-spin :spinning="flowLoading">
|
||||||
<apexchart
|
<apexchart
|
||||||
v-if="flowData.length"
|
v-if="flowData.length"
|
||||||
type="bar"
|
type="bar"
|
||||||
height="220"
|
:height="flowChartHeight"
|
||||||
:options="flowChartOptions"
|
:options="flowChartOptions"
|
||||||
:series="flowChartOptions.series"
|
:series="flowChartOptions.series"
|
||||||
/>
|
/>
|
||||||
@@ -176,29 +190,27 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Price chart -->
|
<!-- Price chart -->
|
||||||
<section
|
<section class="dashboard-panel mt-2">
|
||||||
class="mt-2 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900"
|
<div class="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
class="!mb-0 min-w-0 flex-1"
|
class="!mb-0 min-w-0 flex-1"
|
||||||
title="نمودار مظنه"
|
title="نمودار مظنه"
|
||||||
:help="copy.charts.priceChart"
|
:help="copy.charts.priceChart"
|
||||||
/>
|
/>
|
||||||
<a-segmented
|
<a-radio-group
|
||||||
v-model:value="priceRange"
|
v-model:value="priceRange"
|
||||||
|
button-style="solid"
|
||||||
size="small"
|
size="small"
|
||||||
class="mt-0.5 shrink-0"
|
class="dashboard-price-range shrink-0"
|
||||||
:options="[
|
>
|
||||||
{ label: '۲۴س', value: 'DAILY' },
|
<a-radio-button value="DAILY">24 ساعت اخیر</a-radio-button>
|
||||||
{ label: 'هفته', value: 'WEEKLY' },
|
<a-radio-button value="WEEKLY">هفته گذشته</a-radio-button>
|
||||||
{ label: 'ماه', value: 'MONTHLY' },
|
<a-radio-button value="MONTHLY">ماه گذشته</a-radio-button>
|
||||||
]"
|
</a-radio-group>
|
||||||
@change="switchPriceRange"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<apexchart
|
<apexchart
|
||||||
v-if="priceSeries.length"
|
v-if="priceSeries.length"
|
||||||
|
:key="priceRange"
|
||||||
type="area"
|
type="area"
|
||||||
height="200"
|
height="200"
|
||||||
:options="priceChartOptions"
|
:options="priceChartOptions"
|
||||||
@@ -212,52 +224,103 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Orders -->
|
<!-- Orders + side charts -->
|
||||||
<section class="mt-2 grid grid-cols-1 gap-2 xl:grid-cols-3">
|
<section class="mt-2 grid grid-cols-1 gap-2 xl:grid-cols-3">
|
||||||
<div
|
<div class="flex flex-col gap-2 xl:col-span-1">
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50/40 p-3 dark:border-amber-800/50 dark:bg-amber-950/25 xl:col-span-1"
|
<div class="dashboard-panel">
|
||||||
>
|
<div class="mb-2 flex items-start justify-between gap-2">
|
||||||
<div class="mb-2 flex items-start justify-between gap-2">
|
<DashboardSectionHeader
|
||||||
<DashboardSectionHeader
|
class="!mb-0 min-w-0 flex-1"
|
||||||
class="!mb-0 min-w-0 flex-1"
|
title="ساعات شلوغ سفارش"
|
||||||
title="سفارشات باز"
|
:help="copy.orders.peakHours"
|
||||||
:help="copy.orders.pending"
|
/>
|
||||||
/>
|
<a-radio-group
|
||||||
<a-badge :count="pendingOrders.length" :overflow-count="99" />
|
v-model:value="peakHoursRange"
|
||||||
</div>
|
button-style="solid"
|
||||||
<div v-if="pendingOrders.length" class="max-h-64 space-y-1.5 overflow-y-auto">
|
size="small"
|
||||||
<div
|
class="shrink-0"
|
||||||
v-for="order in pendingOrders"
|
>
|
||||||
:key="order.id"
|
<a-radio-button :value="7">۷ روز</a-radio-button>
|
||||||
class="rounded-lg border border-amber-200/80 bg-white p-2 dark:border-amber-800/40 dark:bg-slate-900"
|
<a-radio-button :value="30">۳۰ روز</a-radio-button>
|
||||||
>
|
</a-radio-group>
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-bold text-slate-900 dark:text-slate-100">#{{ order.id }}</span>
|
|
||||||
<span class="rounded-lg px-2 py-0.5 text-xs" :class="directionClass(order.direction)">
|
|
||||||
{{ directionLabel(order.direction) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 truncate text-sm text-slate-800 dark:text-slate-200">
|
|
||||||
{{ order.customer?.name ?? '—' }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-2 grid grid-cols-2 gap-1 text-xs text-slate-700 dark:text-slate-300">
|
|
||||||
<span>مقدار: {{ formatGram(order.amount) }}</span>
|
|
||||||
<span>مظنه: {{ usePrice(order.price_original ?? 0, true, '') }}</span>
|
|
||||||
<span>قیمت مشتری: {{ usePrice(order.price ?? 0, true, '') }}</span>
|
|
||||||
<span class="font-medium text-emerald-700 dark:text-emerald-400">
|
|
||||||
سود: {{ usePrice(order.total_fee ?? 0, true, '') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="peakHoursData?.peak_hour != null && peakHoursData.peak_count > 0"
|
||||||
|
class="mb-3 flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-semibold text-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
شلوغترین: {{ formatPeakHourLabel(peakHoursData.peak_hour) }}
|
||||||
|
<span class="mr-1 font-normal opacity-75">({{ peakHoursData.peak_count }} سفارش)</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="slot in topPeakHours.slice(1)"
|
||||||
|
:key="slot.hour"
|
||||||
|
class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{{ formatPeakHourLabel(slot.hour) }} · {{ slot.total }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin :spinning="peakHoursLoading">
|
||||||
|
<apexchart
|
||||||
|
v-if="peakHoursData?.total_orders"
|
||||||
|
type="bar"
|
||||||
|
:height="peakHoursChartHeight"
|
||||||
|
:options="peakHoursChartOptions"
|
||||||
|
:series="peakHoursChartOptions.series"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-44 items-center justify-center text-xs text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
در این بازه سفارشی ثبت نشده
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="py-6 text-center text-xs text-amber-800/70 dark:text-amber-300/70">
|
|
||||||
سفارش بازی نیست
|
<div class="dashboard-panel">
|
||||||
|
<div class="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<DashboardSectionHeader
|
||||||
|
class="!mb-0 min-w-0 flex-1"
|
||||||
|
title="فعالیت محصولات"
|
||||||
|
:help="copy.charts.productActivity"
|
||||||
|
/>
|
||||||
|
<a-radio-group
|
||||||
|
v-model:value="productActivityRange"
|
||||||
|
button-style="solid"
|
||||||
|
size="small"
|
||||||
|
class="dashboard-product-range shrink-0"
|
||||||
|
>
|
||||||
|
<a-radio-button value="day">روزانه</a-radio-button>
|
||||||
|
<a-radio-button value="week">هفتگی</a-radio-button>
|
||||||
|
<a-radio-button value="month">ماهانه</a-radio-button>
|
||||||
|
<a-radio-button value="year">سالانه</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
<a-spin :spinning="productActivityLoading">
|
||||||
|
<div v-if="productReportItems.length" class="dashboard-product-chart min-w-0">
|
||||||
|
<apexchart
|
||||||
|
:key="`${productActivityRange}-${productChartHeight}-${productChartLabelWidth}`"
|
||||||
|
type="bar"
|
||||||
|
:height="productChartHeight"
|
||||||
|
:options="productChartOptions"
|
||||||
|
:series="productChartOptions.series"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-44 items-center justify-center text-xs text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
در این بازه معاملهای برای محصولات ثبت نشده
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="dashboard-panel xl:col-span-2">
|
||||||
class="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900 xl:col-span-2"
|
|
||||||
>
|
|
||||||
<div class="mb-2 flex items-start justify-between gap-2">
|
<div class="mb-2 flex items-start justify-between gap-2">
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
class="!mb-0 min-w-0 flex-1"
|
class="!mb-0 min-w-0 flex-1"
|
||||||
@@ -322,63 +385,60 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bottom row -->
|
<!-- Bottom row -->
|
||||||
<section class="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-2">
|
<section class="mt-2 grid grid-cols-1 gap-2 xl:grid-cols-2">
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900">
|
<div class="dashboard-panel">
|
||||||
<DashboardSectionHeader title="مشتریان" :help="copy.customers" />
|
<DashboardSectionHeader title="مشتریان" :help="copy.customers" />
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2.5 md:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
v-for="item in customerMetricItems"
|
v-for="item in customerMetricItems"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
class="rounded-lg px-2.5 py-2"
|
class="dashboard-metric-cell"
|
||||||
:class="item.bgClass"
|
:class="item.bgClass"
|
||||||
>
|
>
|
||||||
<DashboardLabelWithHelp
|
<DashboardLabelWithHelp
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
:help="item.help"
|
:help="item.help"
|
||||||
size="xs"
|
size="xs"
|
||||||
text-class="text-[10px]"
|
text-class="text-[11px]"
|
||||||
:label-class="item.labelClass"
|
:label-class="item.labelClass"
|
||||||
|
root-class="dashboard-metric-label"
|
||||||
/>
|
/>
|
||||||
<p class="text-lg font-bold" :class="item.valueClass">{{ item.value }}</p>
|
<p class="dashboard-metric-value" :class="item.valueClass">{{ item.value }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900">
|
<div class="dashboard-panel">
|
||||||
<DashboardSectionHeader title="موجودی کیف پولها" :help="copy.wallet" />
|
<DashboardSectionHeader title="موجودی کیف پولها" :help="copy.wallet" />
|
||||||
<div class="space-y-1.5 text-sm text-slate-800 dark:text-slate-200">
|
<div class="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="item in walletMetricItems"
|
v-for="item in walletMetricItems"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
class="flex items-center justify-between gap-2 rounded-lg px-2.5 py-2"
|
class="dashboard-metric-cell"
|
||||||
:class="item.bgClass"
|
:class="item.bgClass"
|
||||||
>
|
>
|
||||||
<DashboardLabelWithHelp
|
<DashboardLabelWithHelp
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
:help="item.help"
|
:help="item.help"
|
||||||
size="sm"
|
size="xs"
|
||||||
label-class="text-slate-800 dark:text-slate-200"
|
text-class="text-[11px]"
|
||||||
|
label-class="text-slate-600 dark:text-slate-400"
|
||||||
|
root-class="dashboard-metric-label"
|
||||||
/>
|
/>
|
||||||
<span class="shrink-0 font-bold text-slate-900 dark:text-slate-50">{{ item.value }}</span>
|
<p class="dashboard-metric-value text-slate-900 dark:text-slate-50">{{ item.value }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
v-if="(master?.GT_products_report?.length ?? 0) > 0"
|
|
||||||
class="mt-2 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-600 dark:bg-slate-900"
|
|
||||||
>
|
|
||||||
<DashboardSectionHeader title="فعالیت محصولات" :help="copy.charts.productActivity" />
|
|
||||||
<apexchart type="bar" height="220" :options="productChartOptions" :series="productChartOptions.series" />
|
|
||||||
</section>
|
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
|
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
|
||||||
|
import DashboardQuickActionsBar from '@/pages/dashboard/components/DashboardQuickActionsBar.vue'
|
||||||
|
import DashboardQuickWalletModal from '@/pages/dashboard/components/DashboardQuickWalletModal.vue'
|
||||||
import DashboardSectionHeader from '@/pages/dashboard/components/DashboardSectionHeader.vue'
|
import DashboardSectionHeader from '@/pages/dashboard/components/DashboardSectionHeader.vue'
|
||||||
import DashboardStatCard from '@/pages/dashboard/components/DashboardStatCard.vue'
|
import DashboardStatCard from '@/pages/dashboard/components/DashboardStatCard.vue'
|
||||||
import {
|
import {
|
||||||
@@ -395,6 +455,7 @@ import {
|
|||||||
dashboardWalletMetrics,
|
dashboardWalletMetrics,
|
||||||
} from '@/pages/dashboard/dashboardSectionCopy'
|
} from '@/pages/dashboard/dashboardSectionCopy'
|
||||||
import { useDashboardPage } from '@/pages/dashboard/composables/useDashboardPage'
|
import { useDashboardPage } from '@/pages/dashboard/composables/useDashboardPage'
|
||||||
|
import { useDashboardQuickActions } from '@/pages/dashboard/composables/useDashboardQuickActions'
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
hero: dashboardHero,
|
hero: dashboardHero,
|
||||||
@@ -417,17 +478,23 @@ const {
|
|||||||
balance,
|
balance,
|
||||||
walletSums,
|
walletSums,
|
||||||
recentOrders,
|
recentOrders,
|
||||||
pendingOrders,
|
peakHoursRange,
|
||||||
customerStats,
|
peakHoursData,
|
||||||
|
peakHoursLoading,
|
||||||
|
topPeakHours,
|
||||||
priceRange,
|
priceRange,
|
||||||
flowRange,
|
flowRange,
|
||||||
flowData,
|
flowData,
|
||||||
priceSeries,
|
priceSeries,
|
||||||
|
productActivityRange,
|
||||||
|
productActivityLoading,
|
||||||
|
productReportItems,
|
||||||
|
productChartHeight,
|
||||||
|
productChartLabelWidth,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
applyDateRange,
|
applyDateRange,
|
||||||
switchPriceRange,
|
|
||||||
loadFlow,
|
loadFlow,
|
||||||
onToggleGateway,
|
loadProductActivity,
|
||||||
formatGram,
|
formatGram,
|
||||||
directionLabel,
|
directionLabel,
|
||||||
directionClass,
|
directionClass,
|
||||||
@@ -435,12 +502,34 @@ const {
|
|||||||
stsLabel,
|
stsLabel,
|
||||||
financeChartOptions,
|
financeChartOptions,
|
||||||
flowChartOptions,
|
flowChartOptions,
|
||||||
|
flowChartHeight,
|
||||||
|
peakHoursChartOptions,
|
||||||
|
peakHoursChartHeight,
|
||||||
|
formatPeakHourLabel,
|
||||||
priceChartOptions,
|
priceChartOptions,
|
||||||
productChartOptions,
|
productChartOptions,
|
||||||
usePrice,
|
usePrice,
|
||||||
toJalali,
|
toJalali,
|
||||||
|
customerStats,
|
||||||
} = useDashboardPage()
|
} = useDashboardPage()
|
||||||
|
|
||||||
|
const {
|
||||||
|
quickActionLoading,
|
||||||
|
hasProductSnapshot,
|
||||||
|
productSnapshotCount,
|
||||||
|
loadSnapshotStatus,
|
||||||
|
deactivateAll,
|
||||||
|
restoreProducts,
|
||||||
|
closeAllBuy,
|
||||||
|
closeAllSell,
|
||||||
|
syncShow,
|
||||||
|
syncPrices,
|
||||||
|
} = useDashboardQuickActions(refreshAll)
|
||||||
|
|
||||||
|
const showQuickWallet = ref(false)
|
||||||
|
|
||||||
|
void loadSnapshotStatus()
|
||||||
|
|
||||||
const dateRangeMetrics = computed(() => [
|
const dateRangeMetrics = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'واریز بانکی',
|
label: 'واریز بانکی',
|
||||||
@@ -517,15 +606,80 @@ const walletMetricItems = computed(() => [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.dashboard-panel {
|
||||||
|
@apply rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-600 dark:bg-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-cell {
|
||||||
|
@apply flex min-h-[4.75rem] flex-col justify-between rounded-lg px-3 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-value {
|
||||||
|
@apply mt-2 text-base font-bold leading-snug break-words;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.dashboard-metric-label) {
|
||||||
|
align-items: flex-start;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.dashboard-metric-label > span:first-child) {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: unset;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-wrap {
|
||||||
|
@apply px-1 pb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.apexcharts-legend) {
|
||||||
|
padding-inline: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.apexcharts-legend-series) {
|
||||||
|
margin-inline: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-v2 :deep(.apexcharts-tooltip) {
|
.dashboard-v2 :deep(.apexcharts-tooltip) {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.dashboard-section-header) {
|
||||||
|
overflow: visible;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.dashboard-section-title) {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-v2 :deep(.dashboard-section-title > span:first-child) {
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow: visible;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-v2 :deep(.dashboard-label-with-help .ant-tooltip-disabled-compatible-wrapper),
|
.dashboard-v2 :deep(.dashboard-label-with-help .ant-tooltip-disabled-compatible-wrapper),
|
||||||
.dashboard-v2 :deep(.dashboard-label-with-help .dashboard-help-trigger) {
|
.dashboard-v2 :deep(.dashboard-label-with-help .dashboard-help-trigger) {
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
line-height: 0 !important;
|
line-height: 1 !important;
|
||||||
vertical-align: middle !important;
|
vertical-align: baseline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-product-chart {
|
||||||
|
@apply min-w-0 px-0.5 md:px-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-product-chart :deep(.apexcharts-yaxis-texts-g text) {
|
||||||
|
text-anchor: start !important;
|
||||||
|
direction: rtl;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-product-chart :deep(.apexcharts-yaxis) {
|
||||||
|
transform: translateX(-2px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Charge, WalletChargeDirection } from '@/model/wallet'
|
import type { Charge, WalletChargeDirection } from '@/model/wallet'
|
||||||
|
|
||||||
export function createEmptyChargeForm(customerId: number): Charge {
|
export function createEmptyChargeForm(customerId?: number): Charge {
|
||||||
return {
|
return {
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
value: undefined,
|
value: undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user