520 lines
17 KiB
Vue
520 lines
17 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">
|
|
<!-- ── KPI row: every stat the two legacy dashboards showed, preserved ── -->
|
|
<div class="kpi-grid kpi-grid--4">
|
|
<StatisticsCardLine
|
|
icon="UsersIcon"
|
|
color="warning"
|
|
:statistic="`${data.customerCount ?? 0} نفر`"
|
|
statistic-title="مشتریان جدید (۳۰ روز گذشته)"
|
|
/>
|
|
<StatisticsCardLine
|
|
icon="ShoppingBagIcon"
|
|
color="primary"
|
|
:statistic="`${data.allgram ?? 0} گرم`"
|
|
statistic-title="طلای فروش رفته (۳۰ روز گذشته)"
|
|
/>
|
|
<StatisticsCardLine
|
|
icon="ShoppingBagIcon"
|
|
color="success"
|
|
:statistic="`${data.ordercount ?? 0} سفارش`"
|
|
statistic-title="تعداد سفارشات (۳۰ روز گذشته)"
|
|
/>
|
|
<StatisticsCardLine
|
|
icon="DollarSignIcon"
|
|
color="danger"
|
|
:statistic="goldPriceLabel"
|
|
statistic-title="نرخ هر گرم طلای ۱۸ عیار"
|
|
/>
|
|
</div>
|
|
|
|
<div class="kpi-grid kpi-grid--2">
|
|
<StatisticsCardLine
|
|
icon="BarChartIcon"
|
|
color="primary"
|
|
:statistic="`${data.sumLastOrder ?? 0} سفارش`"
|
|
statistic-title="تعداد سفارشات تحویلشده (۳۰ روز گذشته)"
|
|
/>
|
|
<StatisticsCardLine
|
|
icon="CreditCardIcon"
|
|
color="warning"
|
|
:statistic="`${totalDebtLabel} گرم`"
|
|
statistic-title="مجموع بدهی طلای مشتریان"
|
|
/>
|
|
</div>
|
|
|
|
<!-- ── روند سفارشها: 30-day line chart, تک vs عمده ── -->
|
|
<a-card title="روند سفارشها (۳۰ روز گذشته)" class="dash-card">
|
|
<apexchart
|
|
type="area"
|
|
:height="chartHeight"
|
|
:options="trendChartOptions"
|
|
:series="trendSeries"
|
|
/>
|
|
</a-card>
|
|
|
|
<div class="card-grid">
|
|
<a-card title="وضعیت سفارشات (۳۰ روز گذشته)" class="dash-card">
|
|
<apexchart type="donut" :height="chartHeight" :options="statusChartOptions" :series="statusSeries" />
|
|
</a-card>
|
|
|
|
<a-card title="تفکیک عمده / تک (۳۰ روز گذشته)" class="dash-card">
|
|
<apexchart type="donut" :height="chartHeight" :options="channelChartOptions" :series="channelSeries" />
|
|
</a-card>
|
|
</div>
|
|
|
|
<div class="card-grid">
|
|
<a-card title="عملکرد کارشناسان فروش" class="dash-card">
|
|
<apexchart
|
|
v-if="agentChartSeries[0]?.data.length"
|
|
type="bar"
|
|
:height="barChartHeight"
|
|
:options="agentChartOptions"
|
|
:series="agentChartSeries"
|
|
/>
|
|
<a-table
|
|
class="dash-table"
|
|
size="small"
|
|
:data-source="data.reagent"
|
|
:columns="agentColumns"
|
|
:pagination="false"
|
|
:scroll="{ x: 'max-content' }"
|
|
row-key="id"
|
|
/>
|
|
</a-card>
|
|
|
|
<a-card title="محصولات پرفروش (۳۰ روز گذشته)" class="dash-card">
|
|
<apexchart
|
|
v-if="productChartSeries[0]?.data.length"
|
|
type="bar"
|
|
:height="barChartHeight"
|
|
:options="productChartOptions"
|
|
:series="productChartSeries"
|
|
/>
|
|
<a-empty v-else description="دادهای برای نمایش وجود ندارد" />
|
|
</a-card>
|
|
</div>
|
|
|
|
<a-card title="میزان فروش" class="dash-card">
|
|
<div class="sales-summary">
|
|
<div class="sales-summary__item">
|
|
<span class="sales-summary__label">تعداد سفارش (۳۰ روز)</span>
|
|
<span class="sales-summary__value">{{ data.sumLastOrder ?? 0 }}</span>
|
|
</div>
|
|
<div class="sales-summary__item">
|
|
<span class="sales-summary__label">مجموع وزن (۳۰ روز)</span>
|
|
<span class="sales-summary__value">{{ data.sumLastOrderMonth ?? 0 }} گرم</span>
|
|
</div>
|
|
<div class="sales-summary__item">
|
|
<span class="sales-summary__label">میانگین هر سفارش</span>
|
|
<span class="sales-summary__value">{{ avgWeightPerOrder }} گرم</span>
|
|
</div>
|
|
</div>
|
|
</a-card>
|
|
|
|
<a-card title="سفارشات هفته جاری" class="dash-card">
|
|
<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
|
|
size="small"
|
|
: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>
|
|
</a-table>
|
|
</a-card>
|
|
</div>
|
|
</a-spin>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
|
import StatisticsCardLine from '../components/statistics-cards/StatisticsCardLine.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 barChartHeight = computed(() => (layout.isMobile ? 240 : 280));
|
|
|
|
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 agentColumns = [
|
|
{ title: 'کارشناس فروش', dataIndex: 'name' },
|
|
{ title: 'میزان فروش (گرم)', dataIndex: 'grams_sell' },
|
|
{ title: 'تعداد سفارش', dataIndex: 'order_count' },
|
|
];
|
|
|
|
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] ?? '';
|
|
}
|
|
|
|
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';
|
|
});
|
|
|
|
// ── روند سفارشها: area chart, تک/عمده بهصورت جدا ──────────────────────
|
|
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: { toolbar: { show: false }, fontFamily: 'inherit', parentHeightOffset: 0 },
|
|
colors: [COLOR_PRIMARY, COLOR_SUCCESS],
|
|
dataLabels: { enabled: false },
|
|
stroke: { curve: 'smooth', width: 2 },
|
|
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0.05 } },
|
|
legend: { position: 'top', horizontalAlign: 'right', fontSize: layout.isMobile ? '12px' : '14px' },
|
|
grid: { padding: layout.isMobile ? { left: 4, right: 4 } : {} },
|
|
xaxis: {
|
|
// phones drop the year (1405/07/04 -> 07/04) so the edge labels aren't
|
|
// clipped; the tooltip below still shows the full date
|
|
categories: (data.ordersTrend ?? []).map((d: OrdersTrendPoint) => {
|
|
const label = String(jdate(d.date) ?? '');
|
|
return layout.isMobile ? label.slice(5) : label;
|
|
}),
|
|
// 30 daily labels overlap at rotate: 0 (see screenshot); angle them so
|
|
// each date stays legible instead of running into its neighbours.
|
|
tickAmount: layout.isMobile ? 6 : undefined,
|
|
labels: {
|
|
rotate: -45,
|
|
rotateAlways: true,
|
|
hideOverlappingLabels: true,
|
|
style: { fontSize: layout.isMobile ? '10px' : '12px', fontWeight: 500 },
|
|
},
|
|
},
|
|
yaxis: { labels: { style: { fontSize: layout.isMobile ? '10px' : '12px' } } },
|
|
tooltip: {
|
|
shared: true,
|
|
x: {
|
|
formatter: (_v: unknown, opts: { dataPointIndex: number }) =>
|
|
jdate((data.ordersTrend ?? [])[opts?.dataPointIndex]?.date),
|
|
},
|
|
},
|
|
}));
|
|
|
|
// ── وضعیت سفارشات: donut ────────────────────────────────────────────────
|
|
const statusSeries = computed(() => (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => s.count));
|
|
const statusChartOptions = computed(() => ({
|
|
chart: { fontFamily: 'inherit', parentHeightOffset: 0 },
|
|
labels: (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => s.label),
|
|
colors: [COLOR_WARNING, COLOR_PRIMARY, COLOR_PRIMARY, COLOR_WARNING, COLOR_WARNING, COLOR_SUCCESS, COLOR_SUCCESS, COLOR_DANGER],
|
|
legend: { position: 'bottom', fontSize: layout.isMobile ? '11px' : '14px' },
|
|
dataLabels: { enabled: true },
|
|
}));
|
|
|
|
// ── تفکیک عمده/تک: donut ────────────────────────────────────────────────
|
|
const channelSeries = computed(() => (data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => c.count));
|
|
const channelChartOptions = computed(() => ({
|
|
chart: { fontFamily: 'inherit', parentHeightOffset: 0 },
|
|
labels: (data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => c.label),
|
|
colors: [COLOR_PRIMARY, COLOR_SUCCESS],
|
|
legend: { position: 'bottom', fontSize: layout.isMobile ? '11px' : '14px' },
|
|
dataLabels: { enabled: true },
|
|
}));
|
|
|
|
// ── عملکرد کارشناسان: horizontal bar ────────────────────────────────────
|
|
const agentChartSeries = computed(() => [
|
|
{ name: 'گرم فروختهشده', data: (data.reagent ?? []).map((r: any) => Number(r.grams_sell ?? 0)) },
|
|
]);
|
|
const agentChartOptions = computed(() => ({
|
|
chart: { toolbar: { show: false }, fontFamily: 'inherit', parentHeightOffset: 0 },
|
|
plotOptions: { bar: { horizontal: true, borderRadius: 4 } },
|
|
colors: [COLOR_PRIMARY],
|
|
dataLabels: { enabled: true },
|
|
xaxis: {
|
|
categories: (data.reagent ?? []).map((r: any) => r.name),
|
|
labels: { style: { fontSize: layout.isMobile ? '10px' : '12px' } },
|
|
},
|
|
yaxis: { labels: { maxWidth: layout.isMobile ? 120 : 160, style: { fontSize: layout.isMobile ? '11px' : '12px' } } },
|
|
}));
|
|
|
|
// ── محصولات پرفروش: 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: { toolbar: { show: false }, fontFamily: 'inherit', parentHeightOffset: 0 },
|
|
plotOptions: {
|
|
bar: layout.isMobile
|
|
? { horizontal: true, borderRadius: 4, barHeight: '60%' }
|
|
: { borderRadius: 4, columnWidth: '55%' },
|
|
},
|
|
colors: [COLOR_SUCCESS],
|
|
dataLabels: { enabled: false },
|
|
xaxis: {
|
|
categories: (data.topProducts ?? []).map((p: TopProductRow) => p.title),
|
|
labels: { style: { fontSize: layout.isMobile ? '10px' : '12px' } },
|
|
},
|
|
yaxis: { labels: { maxWidth: layout.isMobile ? 120 : 160, style: { fontSize: layout.isMobile ? '11px' : '12px' } } },
|
|
}));
|
|
|
|
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>
|
|
.dashboard {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.kpi-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
.kpi-grid--4 {
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
}
|
|
.kpi-grid--2,
|
|
.card-grid {
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
}
|
|
.card-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
.card-grid > *,
|
|
.kpi-grid > * {
|
|
min-width: 0;
|
|
}
|
|
|
|
.dash-table {
|
|
margin-top: 12px;
|
|
}
|
|
|
|
/* ── میزان فروش ── */
|
|
.sales-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
border: 1px solid #f0f0f0;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.sales-summary__item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 12px 16px;
|
|
}
|
|
.sales-summary__item + .sales-summary__item {
|
|
border-right: 1px solid #f0f0f0;
|
|
}
|
|
.sales-summary__label {
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
.sales-summary__value {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1c2d56;
|
|
}
|
|
|
|
/* ── سفارشات هفته جاری (phone list) ── */
|
|
.order-list :deep(.ant-list-pagination) {
|
|
margin-top: 12px;
|
|
}
|
|
.order-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 4px;
|
|
padding: 10px 0;
|
|
}
|
|
.order-item__head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
.order-item__name {
|
|
font-weight: 600;
|
|
color: #1c2d56;
|
|
}
|
|
.order-item__tag {
|
|
margin: 0;
|
|
}
|
|
.order-item__customer {
|
|
font-size: 13px;
|
|
color: #444;
|
|
}
|
|
.order-item__meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
|
|
@media (max-width: 991px) {
|
|
.dashboard,
|
|
.kpi-grid,
|
|
.card-grid {
|
|
gap: 12px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 575px) {
|
|
.kpi-grid--4,
|
|
.kpi-grid--2 {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
.card-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.dash-card :deep(.ant-card-head) {
|
|
min-height: 44px;
|
|
padding: 0 12px;
|
|
}
|
|
.dash-card :deep(.ant-card-head-title) {
|
|
font-size: 14px;
|
|
padding: 10px 0;
|
|
}
|
|
.dash-card :deep(.ant-card-body) {
|
|
padding: 12px;
|
|
}
|
|
|
|
.sales-summary {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
.sales-summary__item {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 12px;
|
|
}
|
|
.sales-summary__item + .sales-summary__item {
|
|
border-right: none;
|
|
border-top: 1px solid #f0f0f0;
|
|
}
|
|
.sales-summary__value {
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
</style>
|