fix chart

This commit is contained in:
2026-06-21 20:27:13 +03:30
parent bbca1c8991
commit 5586985d1e
21 changed files with 1727 additions and 1147 deletions
@@ -0,0 +1,44 @@
<template>
<a-tooltip placement="top" :overlay-inner-style="{ maxWidth: '300px', direction: 'rtl' }">
<template #title>
<div class="space-y-1.5 text-xs leading-5">
<p>{{ help.description }}</p>
<p v-if="help.formula" class="border-t border-white/20 pt-1.5 text-white/90">
<span class="font-semibold">فرمول:</span> {{ help.formula }}
</p>
</div>
</template>
<span
class="dashboard-help-trigger inline-flex shrink-0 items-center justify-center"
role="button"
tabindex="-1"
@click.stop
>
<IosInfoBadge :size="badgeSize" />
</span>
</a-tooltip>
</template>
<script setup lang="ts">
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import IosInfoBadge from '@/pages/dashboard/components/IosInfoBadge.vue'
const props = withDefaults(
defineProps<{
help: DashboardHelp
size?: 'xs' | 'sm' | 'md'
}>(),
{
size: 'sm',
},
)
const badgeSize = computed(() => props.size)
</script>
<style scoped>
.dashboard-help-trigger {
line-height: 0;
cursor: help;
}
</style>
@@ -0,0 +1,45 @@
<template>
<span
class="dashboard-label-with-help inline-flex max-w-full items-center gap-1"
:class="[textClass || sizeTextClass, rootClass]"
>
<span class="truncate leading-none" :class="labelClass">{{ label }}</span>
<DashboardHelpTip v-if="help" :help="help" :size="size" />
</span>
</template>
<script setup lang="ts">
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import DashboardHelpTip from '@/pages/dashboard/components/DashboardHelpTip.vue'
const props = withDefaults(
defineProps<{
label: string
help?: DashboardHelp
size?: 'xs' | 'sm' | 'md'
textClass?: string
labelClass?: string
rootClass?: string
}>(),
{
size: 'xs',
textClass: '',
labelClass: '',
rootClass: '',
},
)
const sizeTextClass = computed(() => {
if (props.size === 'md') return 'text-base'
if (props.size === 'sm') return 'text-sm'
return 'text-[11px]'
})
</script>
<style scoped>
.dashboard-label-with-help :deep(.dashboard-help-trigger) {
display: inline-flex;
align-items: center;
line-height: 0;
}
</style>
@@ -0,0 +1,20 @@
<template>
<div class="mb-2">
<DashboardLabelWithHelp
:label="title"
:help="help"
size="sm"
label-class="font-bold text-slate-900 dark:text-slate-100"
/>
</div>
</template>
<script setup lang="ts">
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
defineProps<{
title: string
help?: DashboardHelp
}>()
</script>
@@ -0,0 +1,74 @@
<template>
<div
class="flex h-full min-h-[4.25rem] items-center gap-2 rounded-xl border border-slate-200/90 bg-white px-2.5 py-2 dark:border-slate-600 dark:bg-slate-900"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
:class="iconBgClass"
>
<BaseIcon :icon="icon" size="17" :class="iconTextClass" />
</div>
<div class="min-w-0 flex-1">
<DashboardLabelWithHelp
:label="title"
:help="help"
size="xs"
label-class="font-medium text-slate-600 dark:text-slate-300"
/>
<p class="truncate text-[15px] font-bold leading-snug text-slate-900 dark:text-slate-50">
{{ value }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
type Tone = 'gold' | 'green' | 'blue' | 'rose' | 'violet' | 'amber'
const props = withDefaults(
defineProps<{
title: string
value: string
help?: DashboardHelp
icon?: string
tone?: Tone
}>(),
{
icon: 'vuesax-linear:chart-2',
tone: 'blue',
},
)
const toneMap: Record<Tone, { iconBg: string; iconText: string }> = {
gold: {
iconBg: 'bg-amber-100 dark:bg-amber-500/25',
iconText: 'text-amber-700 dark:text-amber-300',
},
green: {
iconBg: 'bg-emerald-100 dark:bg-emerald-500/25',
iconText: 'text-emerald-700 dark:text-emerald-300',
},
blue: {
iconBg: 'bg-sky-100 dark:bg-sky-500/25',
iconText: 'text-sky-700 dark:text-sky-300',
},
rose: {
iconBg: 'bg-rose-100 dark:bg-rose-500/25',
iconText: 'text-rose-700 dark:text-rose-300',
},
violet: {
iconBg: 'bg-violet-100 dark:bg-violet-500/25',
iconText: 'text-violet-700 dark:text-violet-300',
},
amber: {
iconBg: 'bg-orange-100 dark:bg-orange-500/25',
iconText: 'text-orange-700 dark:text-orange-300',
},
}
const iconBgClass = computed(() => toneMap[props.tone].iconBg)
const iconTextClass = computed(() => toneMap[props.tone].iconText)
</script>
@@ -0,0 +1,36 @@
<template>
<span
class="ios-info-badge inline-flex shrink-0 items-center justify-center rounded-full border-0 bg-[#F5B800] text-white outline-none ring-0"
:class="sizeClass"
aria-hidden="true"
>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="block h-[72%] w-[72%]">
<rect x="6.75" y="6.75" width="2.5" height="5.5" rx="1.25" fill="currentColor" />
<circle cx="8" cy="4.35" r="1.35" fill="currentColor" />
</svg>
</span>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
size?: 'xs' | 'sm' | 'md'
}>(),
{
size: 'sm',
},
)
const sizeClass = computed(() => {
if (props.size === 'xs') return 'h-[1em] w-[1em] min-h-[0.85rem] min-w-[0.85rem]'
if (props.size === 'md') return 'h-[1.15em] w-[1.15em] min-h-[1.1rem] min-w-[1.1rem]'
return 'h-[1em] w-[1em] min-h-[0.95rem] min-w-[0.95rem]'
})
</script>
<style scoped>
.ios-info-badge {
box-shadow: none;
transform: translateY(-0.5px);
}
</style>
@@ -0,0 +1,309 @@
//@ts-ignore
import moment from 'moment-jalaali'
import { ref, computed, onMounted } from 'vue'
import {
getDashboard,
getDashboardBalance,
getDashboardFinance,
getDashboardFlow,
getPriceChartData,
getRecentOrders,
toggleGateway,
} from '@/api/dashboard'
import { getWalletTable } from '@/api/wallet'
import type {
DashboardFlowPoint,
DashboardFinance,
DashboardMaster,
PriceChartPoint,
} from '@/model/dashboard'
import type { Order } from '@/model/order'
import type { Balance } from '@/model/product'
import { usePrice } from '@core/utils/usePrice'
import { toJalali } from '@core/utils/useDataTime'
import { isStoreApp } from '@/utils/config'
const MESGHAL = 4.331802
export function useDashboardPage() {
const loading = ref(true)
const flowLoading = ref(false)
const priceRange = ref<'DAILY' | 'WEEKLY' | 'MONTHLY'>('DAILY')
const flowRange = ref<'week' | 'month'>('week')
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 master = ref<DashboardMaster>()
const finance = ref<DashboardFinance>()
const balance = ref<Balance>()
const walletSums = ref<{ gold?: number; cash?: number; goldToCash?: number; cashToCash?: number }>()
const recentOrders = ref<Order[]>([])
const flowData = ref<DashboardFlowPoint[]>([])
const priceSeries = ref<PriceChartPoint[]>([])
const isStore = computed(() => isStoreApp())
const pendingOrders = computed(() => master.value?.await_order ?? [])
const customerStats = computed(() => ({
newCount: isStore.value
? master.value?.new_customer_major ?? 0
: master.value?.new_customer_single ?? 0,
total: isStore.value
? master.value?.all_customer_major ?? 0
: master.value?.all_customer_single ?? 0,
active: master.value?.GT_active_customers ?? 0,
inactive: master.value?.GT_deActive_customers ?? 0,
notVerified: master.value?.GT_no_valid_customers ?? 0,
}))
async function loadCore() {
const [dash, fin, bal, wallet, orders, prices] = await Promise.allSettled([
getDashboard(),
getDashboardFinance(startDate.value, endDate.value),
getDashboardBalance(startDate.value, endDate.value),
getWalletTable({ limit: 1, page: 1 } as never),
getRecentOrders(12),
getPriceChartData(),
])
if (dash.status === 'fulfilled') master.value = dash.value
if (fin.status === 'fulfilled') finance.value = fin.value
if (bal.status === 'fulfilled') balance.value = bal.value
if (wallet.status === 'fulfilled') walletSums.value = wallet.value?.sums
if (orders.status === 'fulfilled') recentOrders.value = orders.value
if (prices.status === 'fulfilled') {
priceSeries.value = prices.value?.DAILY ?? []
}
}
async function loadFlow() {
flowLoading.value = true
try {
flowData.value = (await getDashboardFlow(flowRange.value)) ?? []
} catch (e) {
console.error(e)
flowData.value = []
} finally {
flowLoading.value = false
}
}
async function refreshAll() {
loading.value = true
try {
await Promise.all([loadCore(), loadFlow()])
} finally {
loading.value = false
}
}
async function applyDateRange() {
loading.value = true
try {
const [fin, bal] = await Promise.all([
getDashboardFinance(startDate.value, endDate.value),
getDashboardBalance(startDate.value, endDate.value),
])
finance.value = fin
balance.value = bal
} finally {
loading.value = false
}
}
async function switchPriceRange(range?: 'DAILY' | 'WEEKLY' | 'MONTHLY') {
const key = range ?? priceRange.value
priceRange.value = key
const prices = await getPriceChartData()
priceSeries.value = prices?.[key] ?? []
}
async function onToggleGateway() {
await toggleGateway()
master.value = await getDashboard()
}
function formatGram(value?: number) {
return `${Number(value ?? 0).toLocaleString('fa-IR', { maximumFractionDigits: 3 })} گرم`
}
function directionLabel(direction?: number) {
return direction === 1 ? 'خرید' : direction === 2 ? 'فروش' : '—'
}
function directionClass(direction?: number) {
return direction === 1
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300'
}
function stsClass(sts?: number) {
if (sts === 2) {
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
}
if (sts === 1) {
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
}
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300'
}
function stsLabel(order: Order) {
return (order as Order & { sts_str?: string }).sts_str
?? (order.sts === 2 ? 'موفق' : order.sts === 1 ? 'باز' : 'ناموفق')
}
const financeChartOptions = computed(() => {
const sood = finance.value?.fullPriceSood ?? 0
const karmozd = finance.value?.fullPriceKarmozd ?? finance.value?.fullPriceKaermozd ?? 0
const gifts = finance.value?.GiftsPrise ?? 0
return {
chart: { type: 'donut', fontFamily: 'inherit' },
labels: ['سود معامله', 'کارمزد طلایی', 'هدایای طلا'],
colors: ['#10b981', '#f59e0b', '#8b5cf6'],
legend: { position: 'bottom', fontSize: '12px' },
dataLabels: { enabled: false },
plotOptions: {
pie: {
donut: {
size: '68%',
labels: {
show: true,
total: {
show: true,
label: 'درآمد خالص',
formatter: () => usePrice(finance.value?.sumAll ?? 0, true, ''),
},
},
},
},
},
tooltip: {
y: { formatter: (v: number) => usePrice(v, true, '') },
},
series: [sood, karmozd, gifts],
}
})
const flowChartOptions = computed(() => {
const points = [...flowData.value].sort((a, b) =>
String(a.date ?? '').localeCompare(String(b.date ?? '')),
)
const categories = points.map((p) => {
const d = p.date ?? ''
return d ? toJalali(`${d} 12:00:00`, 'jMM/jDD') : d
})
return {
chart: { type: 'bar', stacked: true, toolbar: { show: false }, fontFamily: 'inherit' },
plotOptions: { bar: { borderRadius: 4, columnWidth: '55%' } },
colors: ['#10b981', '#f43f5e'],
xaxis: { categories, labels: { rotate: -35, style: { fontSize: '11px' } } },
yaxis: {
labels: {
formatter: (v: number) => `${Number(v).toLocaleString('fa-IR')} گرم`,
},
},
legend: { position: 'top' },
dataLabels: { enabled: false },
series: [
{ name: 'خرید مشتری', data: points.map((p) => Number(p[1] ?? p['1'] ?? 0)) },
{ name: 'فروش مشتری', data: points.map((p) => Number(p[2] ?? p['2'] ?? 0)) },
],
}
})
const priceChartOptions = computed(() => ({
chart: { type: 'area', toolbar: { show: false }, fontFamily: 'inherit', zoom: { enabled: false } },
stroke: { curve: 'smooth', width: 2 },
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 {
chart: { type: 'bar', toolbar: { show: false }, fontFamily: 'inherit' },
plotOptions: { bar: { horizontal: true, borderRadius: 4 } },
colors: ['#0ea5e9', '#f97316'],
dataLabels: { enabled: false },
xaxis: {
categories: items.map((p) => p.title ?? `#${p.id}`),
labels: { style: { fontSize: '11px' } },
},
legend: { position: 'top' },
series: [
{ name: 'خرید (گرم)', data: items.map((p) => p.transaction_sum_gold_buy ?? 0) },
{
name: 'فروش (گرم)',
data: items.map((p) => Math.abs(p.total_negative_gold_sell ?? 0)),
},
],
}
})
onMounted(() => {
void refreshAll()
})
return {
loading,
flowLoading,
startDate,
endDate,
master,
finance,
balance,
walletSums,
recentOrders,
pendingOrders,
customerStats,
priceRange,
flowRange,
flowData,
priceSeries,
isStore,
MESGHAL,
refreshAll,
applyDateRange,
switchPriceRange,
loadFlow,
onToggleGateway,
formatGram,
directionLabel,
directionClass,
stsClass,
stsLabel,
financeChartOptions,
flowChartOptions,
priceChartOptions,
productChartOptions,
usePrice,
toJalali,
}
}
+139
View File
@@ -0,0 +1,139 @@
/** توضیح و فرمول فارسی — برای tooltip آیکون راهنما */
export type DashboardHelp = {
description: string
formula: string
}
export const MESGHAL_FACTOR = '۴.۳۳۱۸۰۲'
export const dashboardHero: DashboardHelp = {
description: 'آخرین قیمت پایه هر گرم طلا در سیستم.',
formula: `قیمت هر مثقال = قیمت هر گرم × ${MESGHAL_FACTOR}`,
}
export const dashboardGateway: DashboardHelp = {
description: 'وقتی فعال باشد مشتری می‌تواند از درگاه بانکی واریز کند؛ در حالت غیرفعال فقط واریز دستی ممکن است.',
formula: 'وضعیت: فعال یا غیرفعال',
}
export const dashboardKpi = {
profit: {
description: 'مجموع سود اسپرد (تفاوت قیمت خرید/فروش با مشتری) روی همه سفارش‌های ثبت‌شده در بازه.',
formula: 'سود کل = جمع «سود هر سفارش»',
},
commission: {
description: 'مجموع کارمزد طلایی محصول که روی همان سفارش‌ها محاسبه شده.',
formula: 'کارمزد کل = جمع «کارمزد هر سفارش»',
},
orderCount: {
description: 'تعداد همه سفارش‌هایی که در بازه انتخابی ثبت شده‌اند (هر وضعیتی).',
formula: 'تعداد = شمارش سفارش‌ها بین دو تاریخ',
},
goldBalance: {
description: 'تراز وزنی طلا از سفارشات موفق؛ مثبت یعنی به مشتری بیشتر فروخته‌اید.',
formula: 'تراز طلا = گرم فروش − گرم خرید (سفارش موفق)',
},
cashBalance: {
description: 'تراز ریالی همان سفارشات موفق؛ ورود و خروج نقد از معاملات.',
formula: 'تراز ریال = مبلغ فروش − مبلغ خرید',
},
mazanehBalance: {
description: 'میانگین موزون قیمت معاملات، تبدیل‌شده به مثقال.',
formula: `(جمع مبلغ معاملات ÷ جمع گرم) × ${MESGHAL_FACTOR}`,
},
} as const satisfies Record<string, DashboardHelp>
export const dashboardDateRange: DashboardHelp = {
description: 'تاریخ شروع و پایان برای محاسبه کارت‌ها، تراز و نمودارها.',
formula: 'گزارش = از تاریخ … تا تاریخ …',
}
export const dashboardDateMetrics = {
bankDeposit: {
description: 'کل واریزهای موفق مشتریان از درگاه آنلاین در این بازه.',
formula: 'جمع مبلغ واریزهای تأییدشده از درگاه',
},
netIncome: {
description: 'درآمد پس از کسر ارزش طلای هدیه‌داده‌شده.',
formula: 'درآمد خالص = (اختلاف قیمت اصلی و پرداختی) − (گرم هدیه × مظنه)',
},
gifts: {
description: 'طلایی که ادمین به مشتریان هدیه داده در همین بازه.',
formula: 'گرم هدیه = جمع طلای ثبت‌شده در تراکنش‌های ادمین',
},
} as const satisfies Record<string, DashboardHelp>
export const dashboardCharts = {
incomeMix: {
description: 'نشان می‌دهد سود معامله، کارمزد و هدایا چقدر از درآمد کل را تشکیل می‌دهند.',
formula: 'درآمد = سود معامله + کارمزد + (گرم هدیه × مظنه)',
},
buySellVolume: {
description: 'مقدار طلای خرید و فروش مشتری در هر روز (فقط سفارش موفق).',
formula: 'هر روز: گرم خرید مشتری و گرم فروش به مشتری',
},
priceChart: {
description: 'روند تغییر قیمت پایه هر گرم در ۲۴ ساعت، هفته یا ماه.',
formula: 'نمودار = قیمت پایه در هر لحظه',
},
productActivity: {
description: 'مقایسه حجم خرید و فروش هر محصول.',
formula: 'خرید = جمع گرم مثبت | فروش = جمع گرم منفی',
},
} as const satisfies Record<string, DashboardHelp>
export const dashboardOrders = {
pending: {
description: 'سفارش‌هایی که هنوز تأیید نشده‌اند.',
formula: 'سود هر گرم × مقدار = سود کل سفارش',
},
recent: {
description: '۱۲ سفارش آخر با جزئیات مظنه و سود.',
formula: 'مظنه = قیمت پایه | قیمت مشتری = مظنه ± اسپرد | سود/گرم = تفاوت اسپرد',
},
} as const satisfies Record<string, DashboardHelp>
export const dashboardCustomers: DashboardHelp = {
description: 'آمار مشتریان خانگی ثبت‌شده در سیستم.',
formula: 'فعال = دارای کیف پول | بدون احراز = هویت تأیید نشده',
}
export const dashboardCustomerMetrics = {
total: {
description: 'همه مشتریان خانگی ثبت‌شده.',
formula: 'شمارش کل مشتریان',
},
new: {
description: 'مشتریانی که در ۳ ماه اخیر ثبت‌نام کرده‌اند.',
formula: 'ثبت‌نام در ۹۰ روز گذشته',
},
active: {
description: 'مشتریانی که حداقل یک کیف پول دارند.',
formula: 'دارای موجودی در کیف',
},
notVerified: {
description: 'مشتریانی که احراز هویت را تکمیل نکرده‌اند.',
formula: 'وضعیت احراز: ناقص',
},
} as const satisfies Record<string, DashboardHelp>
export const dashboardWallet: DashboardHelp = {
description: 'مجموع موجودی طلا و ریال همه مشتریان.',
formula: 'ارزش طلا به ریال = جمع گرم × مظنه لحظه‌ای',
}
export const dashboardWalletMetrics = {
gold: {
description: 'مجموع طلای موجود در کیف همه مشتریان.',
formula: 'جمع گرم طلا در کیف‌ها',
},
cash: {
description: 'مجموع ریال موجود در کیف همه مشتریان.',
formula: 'جمع ریال در کیف‌ها',
},
goldToCash: {
description: 'ارزش ریالی کل طلای مشتریان با مظنه فعلی.',
formula: 'جمع گرم × قیمت هر گرم',
},
} as const satisfies Record<string, DashboardHelp>
File diff suppressed because it is too large Load Diff