1060 lines
30 KiB
Vue
1060 lines
30 KiB
Vue
<template>
|
|
<!--
|
|
Replaces legacy DashboardECommerce.vue ("آمار عمده فروشی") and
|
|
DashboardAnalytics.vue ("آمار تک فروشی") — two menu entries, two rids,
|
|
two near-identical screens (the retail one rendered almost nothing real;
|
|
see admin-dashboard.service.ts's header comment). Merged into one
|
|
dashboard behind one rid (dashboard.view) and one endpoint
|
|
(GET dashboard/overview), which returns the union of both legacy
|
|
payloads unchanged plus the new chart aggregates used below.
|
|
-->
|
|
<a-spin :spinning="loading">
|
|
<div class="dashboard">
|
|
<!-- ── header ── -->
|
|
<header class="dash-head">
|
|
<div>
|
|
<h1 class="dash-head__title">داشبورد</h1>
|
|
<p class="dash-head__sub">خلاصه عملکرد فروش در ۳۰ روز گذشته</p>
|
|
</div>
|
|
<div class="dash-head__date">
|
|
<CalendarOutlined />
|
|
<span>{{ todayLabel }}</span>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ── KPI row ── -->
|
|
<section class="kpi-grid">
|
|
<div class="kpi kpi--success">
|
|
<div class="kpi__top">
|
|
<span class="kpi__icon"><ShoppingCartOutlined /></span>
|
|
<span class="kpi__label">تعداد سفارشات (۳۰ روز گذشته)</span>
|
|
</div>
|
|
<div class="kpi__value">{{ fmt(data.ordercount) }}<small>سفارش</small></div>
|
|
<apexchart
|
|
v-if="trendTotals.length"
|
|
class="kpi__spark"
|
|
type="area"
|
|
height="56"
|
|
:options="sparkOptions"
|
|
:series="[{ name: 'سفارش', data: trendTotals }]"
|
|
/>
|
|
</div>
|
|
|
|
<div class="kpi kpi--primary">
|
|
<div class="kpi__top">
|
|
<span class="kpi__icon"><GoldOutlined /></span>
|
|
<span class="kpi__label">طلای فروش رفته (۳۰ روز گذشته)</span>
|
|
</div>
|
|
<div class="kpi__value">{{ fmt(data.allgram) }}<small>گرم</small></div>
|
|
<div class="kpi__foot">
|
|
میانگین هر سفارش تحویلشده: <b>{{ avgWeightPerOrder }} گرم</b>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kpi kpi--warning">
|
|
<div class="kpi__top">
|
|
<span class="kpi__icon"><UserAddOutlined /></span>
|
|
<span class="kpi__label">مشتریان جدید (۳۰ روز گذشته)</span>
|
|
</div>
|
|
<div class="kpi__value">{{ fmt(data.customerCount) }}<small>نفر</small></div>
|
|
<div class="kpi__foot">
|
|
مجموع بدهی طلای مشتریان: <b>{{ totalDebtLabel }} گرم</b>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kpi kpi--danger">
|
|
<div class="kpi__top">
|
|
<span class="kpi__icon"><DollarOutlined /></span>
|
|
<span class="kpi__label">نرخ هر گرم طلای ۱۸ عیار</span>
|
|
</div>
|
|
<div class="kpi__value kpi__value--price">{{ goldPriceLabel }}</div>
|
|
<div class="kpi__foot">
|
|
سفارشات تحویلشده (۳۰ روز گذشته): <b>{{ fmt(data.sumLastOrder) }} سفارش</b>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── trend + channel ── -->
|
|
<section class="row row--main">
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">روند سفارشها</h3>
|
|
<p class="panel__sub">۳۰ روز گذشته، تفکیک تک و عمده</p>
|
|
</div>
|
|
<a-segmented v-model:value="trendType" :options="trendTypeOptions" size="small" />
|
|
</div>
|
|
<div class="trend-totals">
|
|
<div class="trend-totals__item">
|
|
<span class="dot" :style="{ background: COLOR_PRIMARY }"></span>
|
|
<span>تک فروشی</span>
|
|
<b>{{ fmt(retailTotal) }}</b>
|
|
</div>
|
|
<div class="trend-totals__item">
|
|
<span class="dot" :style="{ background: COLOR_SUCCESS }"></span>
|
|
<span>عمده فروشی</span>
|
|
<b>{{ fmt(wholesaleTotal) }}</b>
|
|
</div>
|
|
</div>
|
|
<apexchart
|
|
:key="trendType"
|
|
:type="trendType"
|
|
:height="chartHeight"
|
|
:options="trendChartOptions"
|
|
:series="trendSeries"
|
|
/>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">تفکیک عمده / تک</h3>
|
|
<p class="panel__sub">۳۰ روز گذشته</p>
|
|
</div>
|
|
</div>
|
|
<template v-if="channelSeries.some((v: number) => v > 0)">
|
|
<apexchart type="donut" :height="donutHeight" :options="channelChartOptions" :series="channelSeries" />
|
|
<ul class="legend-list">
|
|
<li v-for="(c, i) in data.channelBreakdown" :key="c.channel">
|
|
<span class="dot" :style="{ background: CHANNEL_COLORS[i % CHANNEL_COLORS.length] }"></span>
|
|
<span class="legend-list__label">{{ c.label }}</span>
|
|
<span class="legend-list__meta">{{ fmt(c.grams) }} گرم</span>
|
|
<b>{{ fmt(c.count) }}</b>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
<a-empty v-else description="دادهای برای نمایش وجود ندارد" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── status + sales summary ── -->
|
|
<section class="row row--main">
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">وضعیت سفارشات</h3>
|
|
<p class="panel__sub">۳۰ روز گذشته</p>
|
|
</div>
|
|
<span class="pill">{{ fmt(statusTotal) }} سفارش</span>
|
|
</div>
|
|
<apexchart
|
|
v-if="statusSeries[0].data.length"
|
|
type="bar"
|
|
:height="statusChartHeight"
|
|
:options="statusChartOptions"
|
|
:series="statusSeries"
|
|
/>
|
|
<a-empty v-else description="دادهای برای نمایش وجود ندارد" />
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">میزان فروش</h3>
|
|
<p class="panel__sub">۳۰ روز گذشته</p>
|
|
</div>
|
|
</div>
|
|
<ul class="metric-list">
|
|
<li>
|
|
<span class="metric-list__icon tone-primary"><CheckCircleOutlined /></span>
|
|
<span class="metric-list__label">تعداد سفارش (۳۰ روز)</span>
|
|
<b>{{ fmt(data.sumLastOrder) }}</b>
|
|
</li>
|
|
<li>
|
|
<span class="metric-list__icon tone-success"><GoldOutlined /></span>
|
|
<span class="metric-list__label">مجموع وزن (۳۰ روز)</span>
|
|
<b>{{ fmt(data.sumLastOrderMonth) }} گرم</b>
|
|
</li>
|
|
<li>
|
|
<span class="metric-list__icon tone-warning"><FundOutlined /></span>
|
|
<span class="metric-list__label">میانگین هر سفارش</span>
|
|
<b>{{ avgWeightPerOrder }} گرم</b>
|
|
</li>
|
|
<li>
|
|
<span class="metric-list__icon tone-danger"><CreditCardOutlined /></span>
|
|
<span class="metric-list__label">مجموع بدهی طلای مشتریان</span>
|
|
<b>{{ totalDebtLabel }} گرم</b>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── agents + products ── -->
|
|
<section class="row row--half">
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">عملکرد کارشناسان فروش</h3>
|
|
<p class="panel__sub">بر اساس گرم فروختهشده</p>
|
|
</div>
|
|
</div>
|
|
<ol v-if="data.reagent?.length" class="rank-list">
|
|
<li v-for="(r, i) in data.reagent" :key="r.id ?? i">
|
|
<span class="rank-list__pos" :class="{ 'is-top': i < 3 }">{{ i + 1 }}</span>
|
|
<div class="rank-list__body">
|
|
<div class="rank-list__row">
|
|
<span class="rank-list__name">{{ r.name }}</span>
|
|
<b>{{ fmt(r.grams_sell) }} گرم</b>
|
|
</div>
|
|
<div class="rank-list__bar">
|
|
<span :style="{ width: `${agentShare(r)}%` }"></span>
|
|
</div>
|
|
<span class="rank-list__meta">{{ fmt(r.order_count) }} سفارش</span>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
<a-empty v-else description="دادهای برای نمایش وجود ندارد" />
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">محصولات پرفروش</h3>
|
|
<p class="panel__sub">۳۰ روز گذشته، بر اساس وزن</p>
|
|
</div>
|
|
</div>
|
|
<apexchart
|
|
v-if="productChartSeries[0]?.data.length"
|
|
type="bar"
|
|
:height="barChartHeight"
|
|
:options="productChartOptions"
|
|
:series="productChartSeries"
|
|
/>
|
|
<a-empty v-else description="دادهای برای نمایش وجود ندارد" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── weekly orders ── -->
|
|
<section class="panel">
|
|
<div class="panel__head">
|
|
<div>
|
|
<h3 class="panel__title">سفارشات هفته جاری</h3>
|
|
<p class="panel__sub">مرتبشده بر اساس وزن</p>
|
|
</div>
|
|
<span class="pill">{{ fmt(data.lastOrder?.length ?? 0) }} سفارش</span>
|
|
</div>
|
|
|
|
<a-list
|
|
v-if="layout.isMobile"
|
|
class="order-list"
|
|
:data-source="data.lastOrder"
|
|
:pagination="data.lastOrder?.length > 10 ? { pageSize: 10, size: 'small', align: 'center' } : false"
|
|
row-key="id"
|
|
>
|
|
<template #renderItem="{ item }">
|
|
<a-list-item class="order-item">
|
|
<div class="order-item__head">
|
|
<span class="order-item__name">{{ item.name }}</span>
|
|
<a-tag :color="tagColor(item.statusColor)" class="order-item__tag">{{ item.sts_str }}</a-tag>
|
|
</div>
|
|
<div class="order-item__customer">{{ item.customer }}</div>
|
|
<div class="order-item__meta">
|
|
<span>{{ item.w }} گرم</span>
|
|
<span>{{ jdate(item.caj) }}</span>
|
|
</div>
|
|
</a-list-item>
|
|
</template>
|
|
</a-list>
|
|
|
|
<a-table
|
|
v-else
|
|
class="dash-table"
|
|
size="middle"
|
|
:data-source="data.lastOrder"
|
|
:columns="orderColumns"
|
|
:pagination="{ pageSize: 10 }"
|
|
:scroll="{ x: 'max-content' }"
|
|
row-key="id"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.dataIndex === 'sts_str'">
|
|
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
|
|
</template>
|
|
<template v-else-if="column.dataIndex === 'caj'">
|
|
{{ jdate(record.caj) }}
|
|
</template>
|
|
<template v-else-if="column.dataIndex === 'w'">{{ record.w }} گرم</template>
|
|
</template>
|
|
</a-table>
|
|
</section>
|
|
</div>
|
|
</a-spin>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
|
import {
|
|
CalendarOutlined,
|
|
CheckCircleOutlined,
|
|
CreditCardOutlined,
|
|
DollarOutlined,
|
|
FundOutlined,
|
|
GoldOutlined,
|
|
ShoppingCartOutlined,
|
|
UserAddOutlined,
|
|
} from '@ant-design/icons-vue';
|
|
import { jdate, price } from '../utils/filters';
|
|
import { useLayoutStore } from '../stores/layout';
|
|
import api from '../api/client';
|
|
|
|
const loading = ref(false);
|
|
const layout = useLayoutStore();
|
|
|
|
const chartHeight = computed(() => (layout.isMobile ? 240 : 300));
|
|
const donutHeight = computed(() => (layout.isMobile ? 220 : 240));
|
|
const barChartHeight = computed(() => (layout.isMobile ? 260 : 320));
|
|
|
|
interface OrdersTrendPoint {
|
|
date: string;
|
|
total: number;
|
|
retail: number;
|
|
wholesale: number;
|
|
}
|
|
interface StatusBreakdownRow {
|
|
status: string;
|
|
label: string;
|
|
count: number;
|
|
}
|
|
interface ChannelBreakdownRow {
|
|
channel: string;
|
|
label: string;
|
|
count: number;
|
|
grams: number;
|
|
}
|
|
interface TopProductRow {
|
|
title: string;
|
|
weight: number;
|
|
qty: number;
|
|
}
|
|
|
|
const data = reactive<Record<string, any>>({
|
|
customerCount: 0,
|
|
allgram: 0,
|
|
ordercount: 0,
|
|
reagent: [],
|
|
lastOrder: [],
|
|
sumLastOrder: 0,
|
|
sumLastOrderMonth: 0,
|
|
ordersTrend: [] as OrdersTrendPoint[],
|
|
statusBreakdown: [] as StatusBreakdownRow[],
|
|
channelBreakdown: [] as ChannelBreakdownRow[],
|
|
topProducts: [] as TopProductRow[],
|
|
goldPricePerGram: null as number | null,
|
|
totalCustomerDebtGrams: 0,
|
|
});
|
|
|
|
// ── colours matching the rest of the admin panel (StatisticsCardLine's own
|
|
// icon-color classes and ant's card chrome) ────────────────────────────────
|
|
const COLOR_PRIMARY = '#1c2d56';
|
|
const COLOR_SUCCESS = '#28c76f';
|
|
const COLOR_WARNING = '#ff9f43';
|
|
const COLOR_DANGER = '#ea5455';
|
|
const CHANNEL_COLORS = [COLOR_PRIMARY, COLOR_SUCCESS];
|
|
const STATUS_COLORS = [
|
|
COLOR_WARNING,
|
|
COLOR_PRIMARY,
|
|
COLOR_PRIMARY,
|
|
COLOR_WARNING,
|
|
COLOR_WARNING,
|
|
COLOR_SUCCESS,
|
|
COLOR_SUCCESS,
|
|
COLOR_DANGER,
|
|
];
|
|
|
|
const BASE_CHART = { fontFamily: 'inherit', parentHeightOffset: 0, toolbar: { show: false } };
|
|
const AXIS_LABEL_COLOR = '#8a94a6';
|
|
const GRID = { borderColor: '#eef0f5', strokeDashArray: 4 };
|
|
|
|
const orderColumns = [
|
|
{ title: 'شماره سفارش', dataIndex: 'name' },
|
|
{ title: 'مشتری', dataIndex: 'customer' },
|
|
{ title: 'وزن', dataIndex: 'w' },
|
|
{ title: 'وضعیت', dataIndex: 'sts_str' },
|
|
{ title: 'تاریخ', dataIndex: 'caj' },
|
|
];
|
|
|
|
function tagColor(c: string) {
|
|
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
|
|
}
|
|
|
|
function fmt(v: unknown) {
|
|
return price(v ?? 0) || '0';
|
|
}
|
|
|
|
const todayLabel = computed(() => jdate(new Date().toISOString()));
|
|
|
|
const goldPriceLabel = computed(() =>
|
|
data.goldPricePerGram ? `${price(Math.round(Number(data.goldPricePerGram)))} تومان` : '—',
|
|
);
|
|
|
|
const totalDebtLabel = computed(() => price(Number(data.totalCustomerDebtGrams ?? 0).toFixed(2)));
|
|
|
|
const avgWeightPerOrder = computed(() => {
|
|
const count = Number(data.sumLastOrder ?? 0);
|
|
const weight = Number(data.sumLastOrderMonth ?? 0);
|
|
return count > 0 ? (weight / count).toFixed(2) : '0';
|
|
});
|
|
|
|
// ── KPI sparkline ─────────────────────────────────────────────────────────
|
|
const trendTotals = computed(() => (data.ordersTrend ?? []).map((d: OrdersTrendPoint) => d.total));
|
|
const sparkOptions = {
|
|
chart: { ...BASE_CHART, sparkline: { enabled: true }, animations: { enabled: false } },
|
|
colors: [COLOR_SUCCESS],
|
|
stroke: { curve: 'smooth', width: 2 },
|
|
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0 } },
|
|
tooltip: { enabled: false },
|
|
};
|
|
|
|
// ── روند سفارشها ──────────────────────────────────────────────────────────
|
|
const trendType = ref<'area' | 'bar'>('area');
|
|
const trendTypeOptions = [
|
|
{ label: 'ناحیهای', value: 'area' },
|
|
{ label: 'ستونی', value: 'bar' },
|
|
];
|
|
const retailTotal = computed(() =>
|
|
(data.ordersTrend ?? []).reduce((s: number, d: OrdersTrendPoint) => s + Number(d.retail ?? 0), 0),
|
|
);
|
|
const wholesaleTotal = computed(() =>
|
|
(data.ordersTrend ?? []).reduce((s: number, d: OrdersTrendPoint) => s + Number(d.wholesale ?? 0), 0),
|
|
);
|
|
const trendSeries = computed(() => {
|
|
const trend: OrdersTrendPoint[] = data.ordersTrend ?? [];
|
|
return [
|
|
{ name: 'تک فروشی', data: trend.map((d) => d.retail) },
|
|
{ name: 'عمده فروشی', data: trend.map((d) => d.wholesale) },
|
|
];
|
|
});
|
|
const trendChartOptions = computed(() => ({
|
|
chart: { ...BASE_CHART, stacked: trendType.value === 'bar' },
|
|
colors: [COLOR_PRIMARY, COLOR_SUCCESS],
|
|
dataLabels: { enabled: false },
|
|
stroke: trendType.value === 'area' ? { curve: 'smooth', width: 2.5 } : { width: 0 },
|
|
fill:
|
|
trendType.value === 'area'
|
|
? { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.02 } }
|
|
: { opacity: 1 },
|
|
plotOptions: { bar: { columnWidth: '55%', borderRadius: 3, borderRadiusApplication: 'end' } },
|
|
legend: { show: false },
|
|
grid: { ...GRID, padding: layout.isMobile ? { left: 4, right: 4 } : {} },
|
|
markers: { size: 0, hover: { size: 5 } },
|
|
xaxis: {
|
|
// the axis drops the year (1405/07/04 -> 07/04) so 30 labels fit
|
|
// unrotated; the tooltip below still shows the full date
|
|
categories: (data.ordersTrend ?? []).map((d: OrdersTrendPoint) => String(jdate(d.date) ?? '').slice(5)),
|
|
tickAmount: layout.isMobile ? 6 : 10,
|
|
axisBorder: { show: false },
|
|
axisTicks: { show: false },
|
|
labels: {
|
|
rotate: 0,
|
|
hideOverlappingLabels: true,
|
|
style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '10px' : '12px' },
|
|
},
|
|
},
|
|
yaxis: {
|
|
labels: {
|
|
style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '10px' : '12px' },
|
|
formatter: (v: number) => String(Math.round(v)),
|
|
},
|
|
},
|
|
tooltip: {
|
|
shared: true,
|
|
intersect: false,
|
|
x: {
|
|
formatter: (_v: unknown, opts: { dataPointIndex: number }) =>
|
|
jdate((data.ordersTrend ?? [])[opts?.dataPointIndex]?.date),
|
|
},
|
|
},
|
|
}));
|
|
|
|
// ── تفکیک عمده/تک ────────────────────────────────────────────────────────
|
|
const channelSeries = computed(() =>
|
|
(data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => Number(c.count ?? 0)),
|
|
);
|
|
const channelChartOptions = computed(() => ({
|
|
chart: { ...BASE_CHART },
|
|
labels: (data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => c.label),
|
|
colors: CHANNEL_COLORS,
|
|
legend: { show: false },
|
|
dataLabels: { enabled: false },
|
|
stroke: { width: 3, colors: ['#fff'] },
|
|
plotOptions: {
|
|
pie: {
|
|
donut: {
|
|
size: '74%',
|
|
labels: {
|
|
show: true,
|
|
name: { fontSize: '13px', color: AXIS_LABEL_COLOR, offsetY: 18 },
|
|
value: { fontSize: '16px', fontWeight: 500, color: COLOR_PRIMARY, offsetY: -14 },
|
|
total: {
|
|
show: true,
|
|
label: 'کل سفارشها',
|
|
color: AXIS_LABEL_COLOR,
|
|
fontSize: '13px',
|
|
formatter: (w: { globals: { seriesTotals: number[] } }) =>
|
|
String(w.globals.seriesTotals.reduce((a, b) => a + b, 0)),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
// ── وضعیت سفارشات: distributed horizontal bar ───────────────────────────
|
|
const statusTotal = computed(() =>
|
|
(data.statusBreakdown ?? []).reduce((s: number, r: StatusBreakdownRow) => s + Number(r.count ?? 0), 0),
|
|
);
|
|
const statusSeries = computed(() => [
|
|
{ name: 'تعداد', data: (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => Number(s.count ?? 0)) },
|
|
]);
|
|
const statusChartHeight = computed(() =>
|
|
Math.max(200, (data.statusBreakdown?.length ?? 0) * (layout.isMobile ? 38 : 44) + 40),
|
|
);
|
|
const statusChartOptions = computed(() => ({
|
|
chart: { ...BASE_CHART },
|
|
colors: STATUS_COLORS,
|
|
plotOptions: {
|
|
bar: { horizontal: true, distributed: true, borderRadius: 6, borderRadiusApplication: 'end', barHeight: '58%' },
|
|
},
|
|
legend: { show: false },
|
|
dataLabels: {
|
|
enabled: true,
|
|
textAnchor: 'start',
|
|
offsetX: 8,
|
|
style: { colors: ['#fff'], fontWeight: 500, fontSize: '12px' },
|
|
},
|
|
grid: { ...GRID, xaxis: { lines: { show: true } }, yaxis: { lines: { show: false } } },
|
|
xaxis: {
|
|
categories: (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => s.label),
|
|
axisBorder: { show: false },
|
|
axisTicks: { show: false },
|
|
labels: { style: { colors: AXIS_LABEL_COLOR, fontSize: '11px' } },
|
|
},
|
|
yaxis: {
|
|
labels: { maxWidth: layout.isMobile ? 110 : 160, style: { colors: '#475467', fontSize: '12px', fontWeight: 500 } },
|
|
},
|
|
tooltip: { y: { formatter: (v: number) => `${v} سفارش` } },
|
|
}));
|
|
|
|
// ── عملکرد کارشناسان: ranked list ───────────────────────────────────────
|
|
const agentMax = computed(() =>
|
|
Math.max(1, ...(data.reagent ?? []).map((r: any) => Number(r.grams_sell ?? 0))),
|
|
);
|
|
function agentShare(r: any) {
|
|
return Math.max(2, Math.round((Number(r.grams_sell ?? 0) / agentMax.value) * 100));
|
|
}
|
|
|
|
// ── محصولات پرفروش: bar (horizontal on phones, so long titles stay readable)
|
|
const productChartSeries = computed(() => [
|
|
{ name: 'وزن فروختهشده (گرم)', data: (data.topProducts ?? []).map((p: TopProductRow) => Number(p.weight ?? 0)) },
|
|
]);
|
|
const productChartOptions = computed(() => ({
|
|
chart: { ...BASE_CHART },
|
|
plotOptions: {
|
|
bar: layout.isMobile
|
|
? { horizontal: true, borderRadius: 6, borderRadiusApplication: 'end', barHeight: '60%' }
|
|
: { borderRadius: 8, borderRadiusApplication: 'end', columnWidth: '45%' },
|
|
},
|
|
colors: [COLOR_SUCCESS],
|
|
fill: {
|
|
type: 'gradient',
|
|
gradient: { shade: 'light', type: 'vertical', shadeIntensity: 0.2, opacityFrom: 1, opacityTo: 0.75 },
|
|
},
|
|
dataLabels: { enabled: false },
|
|
grid: GRID,
|
|
xaxis: {
|
|
categories: (data.topProducts ?? []).map((p: TopProductRow) => p.title),
|
|
axisBorder: { show: false },
|
|
axisTicks: { show: false },
|
|
labels: {
|
|
trim: true,
|
|
style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '10px' : '12px' },
|
|
},
|
|
},
|
|
yaxis: {
|
|
labels: { maxWidth: layout.isMobile ? 120 : 160, style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '11px' : '12px' } },
|
|
},
|
|
tooltip: { y: { formatter: (v: number) => `${v} گرم` } },
|
|
}));
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
loading.value = true;
|
|
const { data: res } = await api.get('dashboard/overview');
|
|
Object.assign(data, res);
|
|
// legacy sorted both lists client-side after the fetch
|
|
data.reagent = [...(data.reagent ?? [])].sort(
|
|
(a: any, b: any) => Number(b.grams_sell) - Number(a.grams_sell),
|
|
);
|
|
data.lastOrder = [...(data.lastOrder ?? [])].sort(
|
|
(a: any, b: any) => Number(b.w) - Number(a.w),
|
|
);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
b {
|
|
font-weight: 500;
|
|
}
|
|
.dashboard {
|
|
--c-primary: #1c2d56;
|
|
--c-success: #28c76f;
|
|
--c-warning: #ff9f43;
|
|
--c-danger: #ea5455;
|
|
--ink: #101828;
|
|
--muted: #8a94a6;
|
|
--line: #eef0f5;
|
|
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* ── header ── */
|
|
.dash-head {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.dash-head__title {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: var(--ink);
|
|
}
|
|
.dash-head__sub {
|
|
margin: 4px 0 0;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
.dash-head__date {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 14px;
|
|
border-radius: 12px;
|
|
background: #fff;
|
|
border: 1px solid var(--line);
|
|
color: var(--c-primary);
|
|
font-weight: 500;
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* ── panels ── */
|
|
.panel,
|
|
.kpi {
|
|
min-width: 0;
|
|
background: #fff;
|
|
border: 1px solid var(--line);
|
|
border-radius: 18px;
|
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px -12px rgba(16, 24, 40, 0.08);
|
|
}
|
|
.panel {
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.panel__head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.panel__title {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: var(--ink);
|
|
}
|
|
.panel__sub {
|
|
margin: 2px 0 0;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
.pill {
|
|
flex-shrink: 0;
|
|
padding: 4px 12px;
|
|
border-radius: 999px;
|
|
background: rgba(28, 45, 86, 0.06);
|
|
color: var(--c-primary);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
.dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
gap: 20px;
|
|
}
|
|
.row--main {
|
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
|
}
|
|
.row--half {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
/* ── KPI cards ── */
|
|
.kpi-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 20px;
|
|
}
|
|
.kpi {
|
|
--tone: var(--c-primary);
|
|
--tone-bg: rgba(28, 45, 86, 0.08);
|
|
position: relative;
|
|
overflow: hidden;
|
|
padding: 18px 18px 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
.kpi::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset-inline-start: 0;
|
|
top: 18px;
|
|
bottom: 18px;
|
|
width: 4px;
|
|
border-radius: 4px;
|
|
background: var(--tone);
|
|
}
|
|
.kpi--success { --tone: var(--c-success); --tone-bg: rgba(40, 199, 111, 0.12); }
|
|
.kpi--warning { --tone: var(--c-warning); --tone-bg: rgba(255, 159, 67, 0.14); }
|
|
.kpi--danger { --tone: var(--c-danger); --tone-bg: rgba(234, 84, 85, 0.12); }
|
|
.kpi__top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.kpi__icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 12px;
|
|
display: grid;
|
|
place-items: center;
|
|
flex-shrink: 0;
|
|
font-size: 16px;
|
|
color: var(--tone);
|
|
background: var(--tone-bg);
|
|
}
|
|
.kpi__label {
|
|
font-size: 12.5px;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
}
|
|
.kpi__value {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
line-height: 1.2;
|
|
color: var(--ink);
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 6px;
|
|
}
|
|
.kpi__value small {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
}
|
|
.kpi__value--price {
|
|
font-size: 16px;
|
|
}
|
|
.kpi__foot {
|
|
margin-top: auto;
|
|
padding-top: 10px;
|
|
border-top: 1px dashed var(--line);
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
.kpi__foot b {
|
|
color: var(--ink);
|
|
font-weight: 500;
|
|
}
|
|
.kpi__spark {
|
|
margin: auto -18px -14px;
|
|
}
|
|
|
|
/* ── trend totals ── */
|
|
.trend-totals {
|
|
display: flex;
|
|
gap: 24px;
|
|
margin-bottom: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.trend-totals__item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
.trend-totals__item b {
|
|
font-size: 16px;
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* ── legend list (channel donut) ── */
|
|
.legend-list {
|
|
list-style: none;
|
|
margin: 8px 0 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.legend-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
background: #f8f9fc;
|
|
font-size: 13px;
|
|
}
|
|
.legend-list__label {
|
|
flex: 1;
|
|
color: var(--ink);
|
|
font-weight: 500;
|
|
}
|
|
.legend-list__meta {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
.legend-list b {
|
|
min-width: 32px;
|
|
text-align: left;
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* ── metric list (sales summary) ── */
|
|
.metric-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
flex: 1;
|
|
justify-content: center;
|
|
}
|
|
.metric-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
}
|
|
.metric-list__icon {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 11px;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
.metric-list__label {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
.metric-list b {
|
|
font-size: 15px;
|
|
color: var(--ink);
|
|
white-space: nowrap;
|
|
}
|
|
.tone-primary { color: var(--c-primary); background: rgba(28, 45, 86, 0.08); }
|
|
.tone-success { color: var(--c-success); background: rgba(40, 199, 111, 0.12); }
|
|
.tone-warning { color: var(--c-warning); background: rgba(255, 159, 67, 0.14); }
|
|
.tone-danger { color: var(--c-danger); background: rgba(234, 84, 85, 0.12); }
|
|
|
|
/* ── ranked agents ── */
|
|
.rank-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
max-height: 340px;
|
|
overflow-y: auto;
|
|
padding-inline-end: 4px;
|
|
}
|
|
.rank-list li {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
.rank-list__pos {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 10px;
|
|
display: grid;
|
|
place-items: center;
|
|
flex-shrink: 0;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
background: #f2f4f7;
|
|
}
|
|
.rank-list__pos.is-top {
|
|
color: #fff;
|
|
background: var(--c-primary);
|
|
}
|
|
.rank-list__body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.rank-list__row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
}
|
|
.rank-list__name {
|
|
color: var(--ink);
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.rank-list__row b {
|
|
color: var(--ink);
|
|
white-space: nowrap;
|
|
}
|
|
.rank-list__bar {
|
|
height: 6px;
|
|
margin: 6px 0 4px;
|
|
border-radius: 999px;
|
|
background: #f2f4f7;
|
|
overflow: hidden;
|
|
}
|
|
.rank-list__bar span {
|
|
display: block;
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
background: var(--c-primary);
|
|
transition: width 0.6s ease;
|
|
}
|
|
.rank-list__meta {
|
|
font-size: 11.5px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* ── weekly orders ── */
|
|
.dash-table :deep(.ant-table-thead > tr > th) {
|
|
background: #f8f9fc;
|
|
color: var(--muted);
|
|
font-weight: 500;
|
|
font-size: 12.5px;
|
|
}
|
|
.dash-table :deep(.ant-table-thead > tr > th::before) {
|
|
display: none;
|
|
}
|
|
.order-list :deep(.ant-list-pagination) {
|
|
margin-top: 12px;
|
|
}
|
|
.order-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 4px;
|
|
padding: 12px 0;
|
|
}
|
|
.order-item__head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
.order-item__name {
|
|
font-weight: 500;
|
|
color: var(--c-primary);
|
|
}
|
|
.order-item__tag {
|
|
margin: 0;
|
|
}
|
|
.order-item__customer {
|
|
font-size: 13px;
|
|
color: #475467;
|
|
}
|
|
.order-item__meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* ── responsive ── */
|
|
@media (max-width: 1280px) {
|
|
.kpi-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
@media (max-width: 991px) {
|
|
.dashboard,
|
|
.row,
|
|
.kpi-grid {
|
|
gap: 12px;
|
|
}
|
|
.row--main,
|
|
.row--half {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
@media (max-width: 575px) {
|
|
.dash-head__title {
|
|
font-size: 16px;
|
|
}
|
|
.panel {
|
|
padding: 14px;
|
|
border-radius: 16px;
|
|
}
|
|
.kpi {
|
|
padding: 14px 14px 12px;
|
|
border-radius: 16px;
|
|
}
|
|
.kpi__spark {
|
|
margin: auto -14px -12px;
|
|
}
|
|
.kpi__icon {
|
|
width: 34px;
|
|
height: 34px;
|
|
font-size: 16px;
|
|
}
|
|
.kpi__value {
|
|
font-size: 16px;
|
|
}
|
|
.kpi__value--price {
|
|
font-size: 16px;
|
|
}
|
|
.kpi__label,
|
|
.kpi__foot {
|
|
font-size: 11px;
|
|
}
|
|
.trend-totals__item b {
|
|
font-size: 15px;
|
|
}
|
|
}
|
|
@media (max-width: 420px) {
|
|
.kpi-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
</style>
|