Files
tehran_taj/apps/admin-panel/src/views/statics/index.vue
T
mahmoodandCursor 7dd74ff4f7 feat: product design surveys, cart expiry/buy-later, and catalog polish
Add like/dislike surveys with majority alerts for manufacturing decisions, clearer accounting mapping copy, and related cart/catalog admin-app improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:55:52 +03:30

340 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- Best-selling products and most-viewed products/brands. -->
<a-card title="آمار فروش و بازدید" class="page-card statics">
<template #extra>
<a-segmented v-model:value="days" :options="periodOptions" size="small" @change="loadTop" />
</template>
<a-alert v-if="topError" type="error" show-icon message="آمار بارگذاری نشد." class="statics__alert">
<template #action>
<a-button size="small" @click="loadTop">تلاش دوباره</a-button>
</template>
</a-alert>
<a-spin :spinning="topLoading">
<div class="kpis">
<div v-for="k in kpis" :key="k.label" class="kpi">
<span class="kpi__label">{{ k.label }}</span>
<b class="kpi__value">{{ fa(k.value) }}</b>
</div>
</div>
<section class="panel">
<h3 class="panel__title">پرفروش‌ترین محصولات</h3>
<a-table
:data-source="top.topSoldProducts"
:columns="soldColumns"
:pagination="false"
:locale="{ emptyText: `در ${periodLabel} سفارشی ثبت نشده است.` }"
size="small"
row-key="id"
class="table-scroll"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<span class="pos" :class="{ 'is-top': index < 3 }">{{ fa(index + 1) }}</span>
</template>
<template v-else-if="column.key === 'title'">
<router-link v-if="productLink(record.id)" :to="productLink(record.id)!" class="name">
{{ record.title }}
</router-link>
<span v-else class="name">{{ record.title }}</span>
<span v-if="record.code" class="code">{{ record.code }}</span>
</template>
<template v-else-if="column.key === 'qty'">
<div class="share">
<b>{{ fa(record.qty) }}</b>
<span class="share__bar"><i :style="{ width: `${share(record.qty, soldMax)}%` }" /></span>
</div>
</template>
<template v-else-if="column.key === 'orders'">{{ fa(record.orders) }}</template>
</template>
</a-table>
</section>
<p v-if="viewsNotice" class="statics__notice">
<InfoCircleOutlined />
{{ viewsNotice }}
</p>
<div class="panels-2">
<section class="panel">
<h3 class="panel__title">پربازدیدترین محصولات</h3>
<a-table
:data-source="top.topViewedProducts"
:columns="viewColumns('محصول')"
:pagination="false"
:locale="{ emptyText: viewsEmptyText }"
size="small"
row-key="id"
class="table-scroll"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<span class="pos" :class="{ 'is-top': index < 3 }">{{ fa(index + 1) }}</span>
</template>
<template v-else-if="column.key === 'title'">
<router-link v-if="productLink(record.id)" :to="productLink(record.id)!" class="name">
{{ record.title }}
</router-link>
<span v-else class="name">{{ record.title }}</span>
<span v-if="record.code" class="code">{{ record.code }}</span>
</template>
<template v-else-if="column.key === 'views'">
<div class="share">
<b>{{ fa(record.views) }}</b>
<span class="share__bar"><i :style="{ width: `${share(record.views, viewMax)}%` }" /></span>
</div>
</template>
</template>
</a-table>
</section>
<section class="panel">
<h3 class="panel__title">پربازدیدترین برندها</h3>
<a-table
:data-source="top.topViewedBrands"
:columns="viewColumns('برند')"
:pagination="false"
:locale="{ emptyText: top.totals.views ? 'محصولات بازدیدشده در این بازه برند ندارند.' : viewsEmptyText }"
size="small"
row-key="id"
class="table-scroll"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<span class="pos" :class="{ 'is-top': index < 3 }">{{ fa(index + 1) }}</span>
</template>
<template v-else-if="column.key === 'title'">
<span class="name">{{ record.title }}</span>
</template>
<template v-else-if="column.key === 'views'">
<div class="share">
<b>{{ fa(record.views) }}</b>
<span class="share__bar"><i :style="{ width: `${share(record.views, brandMax)}%` }" /></span>
</div>
</template>
</template>
</a-table>
</section>
</div>
</a-spin>
</a-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { InfoCircleOutlined } from '@ant-design/icons-vue';
import { jdate } from '../../utils/filters';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
interface TopStats {
totals: { soldQty: number; orders: number; views: number; viewedProducts: number };
viewsTrackedSince: string | null;
topSoldProducts: { id: string; title: string; code: string | null; qty: number; orders: number }[];
topViewedProducts: { id: string; title: string; code: string | null; views: number }[];
topViewedBrands: { id: string; title: string; views: number }[];
}
const users = useUsersStore();
const fa = (n: number) => n.toLocaleString('fa-IR');
const days = ref(30);
const periodOptions = [
{ label: '۷ روز', value: 7 },
{ label: '۳۰ روز', value: 30 },
{ label: '۹۰ روز', value: 90 },
{ label: 'همه', value: 0 },
];
const periodLabel = computed(() => (days.value ? `${fa(days.value)} روز گذشته` : 'کل دوره'));
const topLoading = ref(false);
const topError = ref(false);
const top = ref<TopStats>({
totals: { soldQty: 0, orders: 0, views: 0, viewedProducts: 0 },
viewsTrackedSince: null,
topSoldProducts: [],
topViewedProducts: [],
topViewedBrands: [],
});
const kpis = computed(() => [
{ label: 'تعداد فروش', value: top.value.totals.soldQty },
{ label: 'سفارش‌ها', value: top.value.totals.orders },
{ label: 'بازدید محصولات', value: top.value.totals.views },
{ label: 'محصولات دیده‌شده', value: top.value.totals.viewedProducts },
]);
const soldMax = computed(() => Math.max(1, ...top.value.topSoldProducts.map((r) => r.qty)));
const viewMax = computed(() => Math.max(1, ...top.value.topViewedProducts.map((r) => r.views)));
const brandMax = computed(() => Math.max(1, ...top.value.topViewedBrands.map((r) => r.views)));
const share = (value: number, max: number) => Math.max(4, Math.round((value / max) * 100));
const rankColumn = { key: 'rank', title: '#', width: 48, align: 'center' as const };
const soldColumns = [
rankColumn,
{ key: 'title', title: 'محصول' },
{ key: 'qty', title: 'تعداد فروش', width: 180 },
{ key: 'orders', title: 'سفارش', width: 80, align: 'center' as const },
];
const viewColumns = (label: string) => [
rankColumn,
{ key: 'title', title: label },
{ key: 'views', title: 'بازدید', width: 150 },
];
const viewsEmptyText = computed(() =>
top.value.viewsTrackedSince
? `در ${periodLabel.value} بازدیدی ثبت نشده است.`
: 'هنوز بازدیدی ثبت نشده. هر بار که مشتری صفحهٔ یک محصول را در اپ باز کند شمرده می‌شود.',
);
/** Shown when the chosen period starts before view tracking existed. */
const viewsNotice = computed(() => {
const since = top.value.viewsTrackedSince;
if (!since) return null;
const periodStart = days.value ? Date.now() - days.value * 86_400_000 : 0;
if (periodStart >= new Date(since).getTime()) return null;
return `آمار بازدید از ${jdate(since)} ثبت می‌شود؛ قبل از این تاریخ بازدیدی در دست نیست.`;
});
const canEditProduct = computed(() => Boolean(users.hasPermission('product.list.edit')));
function productLink(id: string) {
return canEditProduct.value ? { name: 'product-edit', params: { id } } : null;
}
async function loadTop() {
try {
topLoading.value = true;
topError.value = false;
const { data } = await api.get('admin/misc/stats/top', { params: { days: days.value } });
top.value = data;
} catch (e) {
console.log(e);
topError.value = true;
} finally {
topLoading.value = false;
}
}
onMounted(loadTop);
</script>
<style scoped>
/* The layout lets the page root shrink (min-height: 0); this page must grow with its content. */
.statics {
min-height: auto;
}
.statics__alert {
margin-bottom: 12px;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.kpi {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
background: #f7f8fa;
}
.kpi__label {
font-size: 12.5px;
color: #6b7280;
}
.kpi__value {
font-size: 18px;
font-weight: 700;
color: #1c2d56;
}
.panel {
margin-top: 16px;
}
.panel__title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #1c2d56;
}
.panels-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.statics__notice {
display: flex;
align-items: center;
gap: 6px;
margin: 16px 0 0;
font-size: 12px;
color: #8a909c;
}
.pos {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
font-size: 12px;
color: #6b7280;
background: #f2f4f7;
}
.pos.is-top {
color: #fff;
background: #1c2d56;
}
.name {
color: #2b2f38;
}
a.name:hover {
color: #1c2d56;
text-decoration: underline;
}
.code {
display: inline-block;
margin-right: 8px;
font-size: 12px;
color: #9ca3af;
direction: ltr;
unicode-bidi: isolate;
white-space: nowrap;
}
.share {
display: flex;
align-items: center;
gap: 10px;
}
.share b {
min-width: 28px;
font-weight: 600;
color: #1c2d56;
}
.share__bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: #eef0f3;
overflow: hidden;
}
.share__bar i {
display: block;
height: 100%;
border-radius: inherit;
background: #1c2d56;
}
@media (max-width: 991px) {
.panels-2 {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>