Files
tehran_taj/apps/admin-panel/src/views/Dashboard.vue
T
2026-09-19 13:08:27 +03:30

327 lines
12 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">
<!-- ── KPI row: every stat the two legacy dashboards showed, preserved ── -->
<div class="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="grid-2 mt-4">
<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="mt-4">
<apexchart
type="area"
height="300"
:options="trendChartOptions"
:series="trendSeries"
/>
</a-card>
<div class="grid-2 mt-4">
<a-card title="وضعیت سفارشات (۳۰ روز گذشته)">
<apexchart type="donut" height="300" :options="statusChartOptions" :series="statusSeries" />
</a-card>
<a-card title="تفکیک عمده / تک (۳۰ روز گذشته)">
<apexchart type="donut" height="300" :options="channelChartOptions" :series="channelSeries" />
</a-card>
</div>
<div class="grid-2 mt-4">
<a-card title="عملکرد کارشناسان فروش">
<apexchart
v-if="agentChartSeries[0]?.data.length"
type="bar"
height="280"
:options="agentChartOptions"
:series="agentChartSeries"
/>
<a-table
class="mt-3"
size="small"
:data-source="data.reagent"
:columns="agentColumns"
:pagination="false"
row-key="id"
/>
</a-card>
<a-card title="محصولات پرفروش (۳۰ روز گذشته)">
<apexchart
v-if="productChartSeries[0]?.data.length"
type="bar"
height="280"
:options="productChartOptions"
:series="productChartSeries"
/>
<a-empty v-else description="داده‌ای برای نمایش وجود ندارد" />
</a-card>
</div>
<a-card title="میزان فروش" class="mt-4">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="تعداد سفارش (۳۰ روز)">
{{ data.sumLastOrder ?? 0 }}
</a-descriptions-item>
<a-descriptions-item label="مجموع وزن (۳۰ روز)">
{{ data.sumLastOrderMonth ?? 0 }} گرم
</a-descriptions-item>
<a-descriptions-item label="میانگین هر سفارش">
{{ avgWeightPerOrder }} گرم
</a-descriptions-item>
</a-descriptions>
</a-card>
<a-card title="سفارشات هفته جاری" class="mt-4">
<a-table
size="small"
:data-source="data.lastOrder"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
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>
</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 api from '../api/client';
const loading = ref(false);
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' },
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' },
xaxis: {
categories: (data.ordersTrend ?? []).map((d: OrdersTrendPoint) => jdate(d.date)),
// 30 daily labels overlap at rotate: 0 (see screenshot); angle them so
// each date stays legible instead of running into its neighbours.
labels: {
rotate: -45,
rotateAlways: true,
hideOverlappingLabels: true,
style: { fontSize: '12px', fontWeight: 500 },
},
},
tooltip: { shared: true },
}));
// ── وضعیت سفارشات: donut ────────────────────────────────────────────────
const statusSeries = computed(() => (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => s.count));
const statusChartOptions = computed(() => ({
chart: { fontFamily: 'inherit' },
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' },
dataLabels: { enabled: true },
}));
// ── تفکیک عمده/تک: donut ────────────────────────────────────────────────
const channelSeries = computed(() => (data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => c.count));
const channelChartOptions = computed(() => ({
chart: { fontFamily: 'inherit' },
labels: (data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => c.label),
colors: [COLOR_PRIMARY, COLOR_SUCCESS],
legend: { position: 'bottom' },
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' },
plotOptions: { bar: { horizontal: true, borderRadius: 4 } },
colors: [COLOR_PRIMARY],
dataLabels: { enabled: true },
xaxis: { categories: (data.reagent ?? []).map((r: any) => r.name) },
}));
// ── محصولات پرفروش: bar ─────────────────────────────────────────────────
const productChartSeries = computed(() => [
{ name: 'وزن فروخته‌شده (گرم)', data: (data.topProducts ?? []).map((p: TopProductRow) => Number(p.weight ?? 0)) },
]);
const productChartOptions = computed(() => ({
chart: { toolbar: { show: false }, fontFamily: 'inherit' },
plotOptions: { bar: { borderRadius: 4, columnWidth: '55%' } },
colors: [COLOR_SUCCESS],
dataLabels: { enabled: false },
xaxis: { categories: (data.topProducts ?? []).map((p: TopProductRow) => p.title) },
}));
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>
/* .grid-2/.grid-3/.grid-4 come from the global utilities.less (dedup) */
.mt-4 {
margin-top: 1rem;
}
.mt-3 {
margin-top: 0.75rem;
}
</style>