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>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"apexcharts": "^7.4.0",
|
||||
"axios": "^1.7.7",
|
||||
"dayjs": "^1.11.23",
|
||||
"lodash": "^4.17.21",
|
||||
"moment-jalaali": "^0.10.0",
|
||||
"pinia": "^2.2.0",
|
||||
|
||||
@@ -509,11 +509,14 @@ const dbMismatch = computed(
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
.status--ok {
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
background: #e6f4ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
.status--ok .status__icon {
|
||||
color: #52c41a;
|
||||
color: #1677ff;
|
||||
}
|
||||
.status--ok .status__title {
|
||||
color: #0958d9;
|
||||
}
|
||||
.status--bad {
|
||||
background: #fff2f0;
|
||||
|
||||
@@ -219,6 +219,13 @@ export const adminMenu: NavMenuNode[] = [
|
||||
i18n: 'تغییر درصد محصولات',
|
||||
permissionId:"product.percent"
|
||||
},
|
||||
{
|
||||
url: '/apps/product/survey',
|
||||
name: 'ProductSurvey',
|
||||
slug: 'product-survey',
|
||||
i18n: 'نظرسنجی طرح',
|
||||
permissionId: 'product.survey',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -338,6 +345,7 @@ export const adminMenu: NavMenuNode[] = [
|
||||
name: 'reports and documents',
|
||||
icon: 'FileTextIcon',
|
||||
i18n: 'گزارشات و اسناد مالی',
|
||||
superAdminOnly: true,
|
||||
submenu: [
|
||||
{
|
||||
url: null,
|
||||
@@ -462,7 +470,8 @@ export const adminMenu: NavMenuNode[] = [
|
||||
name: 'report Management',
|
||||
slug: 'manage-report',
|
||||
i18n: 'گزارش',
|
||||
permissionId:"config.report"
|
||||
permissionId:"config.report",
|
||||
superAdminOnly: true
|
||||
},
|
||||
{
|
||||
url: '/config/manage/wallet',
|
||||
@@ -472,6 +481,13 @@ export const adminMenu: NavMenuNode[] = [
|
||||
permissionId:"config.wallet",
|
||||
superAdminOnly: true
|
||||
},
|
||||
{
|
||||
url: '/config/manage/cart-expiry',
|
||||
name: 'cart Expiry',
|
||||
slug: 'manage-cart-expiry',
|
||||
i18n: 'انقضای سبد خرید',
|
||||
permissionId: 'config.cart',
|
||||
},
|
||||
// D27 — Tahesab / Zarrin integration.
|
||||
{
|
||||
url: '/config/manage/accounting',
|
||||
|
||||
@@ -102,6 +102,27 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
no_scroll: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/config/manage/cart-expiry',
|
||||
component: () => import('../views/manage/cart-expiry.vue'),
|
||||
meta: {
|
||||
breadcrumb: [
|
||||
{
|
||||
title: 'خانه',
|
||||
url: '/'
|
||||
},
|
||||
{
|
||||
title: 'انقضای سبد خرید',
|
||||
active: true
|
||||
}
|
||||
],
|
||||
pageTitle: 'انقضای سبد خرید',
|
||||
rule: 'editor',
|
||||
role: 'config.cart',
|
||||
parent: 'manage',
|
||||
no_scroll: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/config/manage/accounting',
|
||||
component: () => import('../views/manage/accounting.vue'),
|
||||
@@ -162,6 +183,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
pageTitle: 'گزارش',
|
||||
rule: 'editor',
|
||||
role: 'config.report',
|
||||
superAdminOnly: true,
|
||||
parent: 'manage',
|
||||
no_scroll: true
|
||||
}
|
||||
@@ -214,6 +236,22 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
no_scroll: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/apps/product/survey',
|
||||
name: 'product-survey',
|
||||
component: () => import('../views/apps/product/ProductSurvey.vue'),
|
||||
meta: {
|
||||
breadcrumb: [
|
||||
{ title: 'خانه', url: '/' },
|
||||
{ title: 'نظرسنجی طرح', active: true },
|
||||
],
|
||||
pageTitle: 'نظرسنجی طرح محصول',
|
||||
rule: 'editor',
|
||||
role: 'product.survey',
|
||||
parent: 'product',
|
||||
no_scroll: true,
|
||||
},
|
||||
},
|
||||
// Category
|
||||
{
|
||||
path: '/category',
|
||||
@@ -259,7 +297,8 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
component: () => import('../views/finical/addFinDoc.vue'),
|
||||
meta: {
|
||||
rule: 'editor',
|
||||
role: 'document.create'
|
||||
role: 'document.create',
|
||||
superAdminOnly: true
|
||||
//no_scroll: true
|
||||
}
|
||||
},
|
||||
@@ -268,7 +307,8 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
component: () => import('../views/finical/addFinDoc.vue'),
|
||||
meta: {
|
||||
rule: 'editor',
|
||||
role: 'document.list.edit'
|
||||
role: 'document.list.edit',
|
||||
superAdminOnly: true
|
||||
//no_scroll: true
|
||||
}
|
||||
},
|
||||
@@ -760,6 +800,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
],*/
|
||||
//pageTitle: 'لیست مشتریان',
|
||||
role: 'report.order.item',
|
||||
superAdminOnly: true,
|
||||
rule: 'editor'
|
||||
}
|
||||
},
|
||||
@@ -769,6 +810,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
component: () => import('../views/report/product.vue'),
|
||||
meta: {
|
||||
role: 'report.product',
|
||||
superAdminOnly: true,
|
||||
rule: 'editor'
|
||||
}
|
||||
},
|
||||
@@ -783,6 +825,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
],*/
|
||||
//pageTitle: 'لیست مشتریان',
|
||||
role: 'document.list',
|
||||
superAdminOnly: true,
|
||||
rule: 'editor'
|
||||
}
|
||||
},
|
||||
@@ -1087,6 +1130,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
],
|
||||
pageTitle: 'افزودن فاکتور',
|
||||
role: 'invoice.create',
|
||||
superAdminOnly: true,
|
||||
rule: 'editor'
|
||||
}
|
||||
},
|
||||
@@ -1097,6 +1141,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
pageTitle: 'ویرایش فاکتورها',
|
||||
role: 'invoice.create',
|
||||
superAdminOnly: true,
|
||||
rule: 'editor'
|
||||
}
|
||||
},
|
||||
@@ -1118,6 +1163,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
||||
],
|
||||
pageTitle: 'لیست فاکتورها',
|
||||
role: 'invoice.list',
|
||||
superAdminOnly: true,
|
||||
rule: 'editor'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,26 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Majority survey alert: half+1 of active customers liked -->
|
||||
<a-alert
|
||||
v-if="surveyMajorityHits.length"
|
||||
type="success"
|
||||
show-icon
|
||||
class="survey-majority"
|
||||
:message="`${surveyMajorityHits.length} طرح نظرسنجی به حد اکثریت رسیده`"
|
||||
>
|
||||
<template #description>
|
||||
<div v-for="hit in surveyMajorityHits" :key="hit.id" class="survey-majority__row">
|
||||
<img v-if="hit.imageUrl" :src="hit.imageUrl" alt="" />
|
||||
<span>
|
||||
<b>{{ hit.title || 'بدون عنوان' }}</b>
|
||||
— ▲ {{ hit.likes }} لایک (حد: {{ surveyMajorityThreshold }})
|
||||
</span>
|
||||
<a-button size="small" type="link" @click="$router.push('/apps/product/survey')">مشاهده و تصمیم</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── KPI row ── -->
|
||||
<section class="kpi-grid">
|
||||
<div class="kpi kpi--success">
|
||||
@@ -303,6 +323,10 @@ const layout = useLayoutStore();
|
||||
// Retail («تک فروشی») figures are for the super admin only; the API also omits them.
|
||||
const users = useUsersStore();
|
||||
const isSuper = computed(() => users.isSuperAdmin);
|
||||
const surveyMajorityHits = ref<
|
||||
{ id: string; title: string | null; imageUrl: string; likes: number }[]
|
||||
>([]);
|
||||
const surveyMajorityThreshold = ref(0);
|
||||
|
||||
const chartHeight = computed(() => (layout.isMobile ? 240 : 300));
|
||||
const donutHeight = computed(() => (layout.isMobile ? 220 : 240));
|
||||
@@ -596,6 +620,16 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
if (users.hasPermission('product.survey')) {
|
||||
try {
|
||||
const { data: maj } = await api.get('admin/product-surveys/majority-alerts');
|
||||
surveyMajorityHits.value = maj?.rows ?? [];
|
||||
surveyMajorityThreshold.value = Number(maj?.majorityThreshold ?? 0);
|
||||
} catch {
|
||||
surveyMajorityHits.value = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -618,6 +652,20 @@ b {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.survey-majority__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.survey-majority__row img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ── header ── */
|
||||
.dash-head {
|
||||
display: flex;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div id="page-user-list" class="survey-page">
|
||||
<a-card v-if="hasPermission('product.survey')" title="نظرسنجی طرح محصول" class="page-card table-scroll">
|
||||
<a-alert
|
||||
v-if="majorityHits.length"
|
||||
type="success"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
:message="`${majorityHits.length} طرح به حد اکثریت رسید (نصف+۱ = ${majorityThreshold} لایک از ${customerCount} مشتری)`"
|
||||
>
|
||||
<template #description>
|
||||
<div v-for="hit in majorityHits" :key="hit.id" class="majority-hit">
|
||||
<img v-if="hit.imageUrl" :src="hit.imageUrl" class="thumb" alt="" />
|
||||
<div class="majority-hit__text">
|
||||
<b>{{ hit.title || 'بدون عنوان' }}</b>
|
||||
<div>▲ {{ hit.likes }} لایک · {{ hit.statusLabel }} · {{ hit.suggestedWeightGrams }} گرم</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="hit.status === 'OPEN' || hit.status === 'CLOSED'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="decide(hit.id, 'APPROVED')"
|
||||
>
|
||||
تأیید ساخت
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
:message="`هشدار اکثریت: وقتی لایک یک طرح به ${majorityThreshold || '—'} برسد (نصف+۱ از ${customerCount || '—'} مشتری)، اینجا دیده میشود`"
|
||||
/>
|
||||
|
||||
<div class="survey-toolbar">
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
allow-clear
|
||||
placeholder="همه وضعیتها"
|
||||
style="width: 180px"
|
||||
:options="statusOptions"
|
||||
@change="load"
|
||||
/>
|
||||
<a-button type="primary" @click="openCreate">ثبت طرح جدید</a-button>
|
||||
<a-button @click="load">تازهسازی</a-button>
|
||||
</div>
|
||||
|
||||
<div class="survey-table-wrap">
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:data-source="rows"
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:locale="{ emptyText: 'طرحی ثبت نشده' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'image'">
|
||||
<img v-if="record.imageUrl" :src="record.imageUrl" class="thumb" alt="" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'title'">
|
||||
{{ record.title || '—' }}
|
||||
<a-tag v-if="record.reachedMajority" color="success" class="ms-1">اکثریت</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'weight'">
|
||||
{{ record.suggestedWeightGrams }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'votes'">
|
||||
<span class="vote like">▲ {{ record.likes }}</span>
|
||||
<span class="vote dislike">▼ {{ record.dislikes }}</span>
|
||||
<div v-if="majorityThreshold" class="vote-prog">حد اکثریت: {{ majorityThreshold }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag>{{ record.statusLabel }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'creator'">
|
||||
<span v-if="record.createdByCustomer">
|
||||
مشتری: {{ record.createdByCustomer.fullName }} ({{ record.createdByCustomer.mobile }})
|
||||
</span>
|
||||
<span v-else-if="record.createdByAdmin">ادمین: {{ record.createdByAdmin.fullName }}</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
v-if="record.status === 'PENDING_REVIEW'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="act(record.id, 'publish')"
|
||||
>
|
||||
انتشار
|
||||
</a-button>
|
||||
<a-button v-if="record.status === 'OPEN'" size="small" @click="act(record.id, 'close')">
|
||||
بستن رأیگیری
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'OPEN' || record.status === 'CLOSED'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="decide(record.id, 'APPROVED')"
|
||||
>
|
||||
تأیید ساخت
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'OPEN' || record.status === 'CLOSED'"
|
||||
size="small"
|
||||
danger
|
||||
@click="decide(record.id, 'REJECTED')"
|
||||
>
|
||||
رد
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="createOpen"
|
||||
title="ثبت طرح برای نظرسنجی"
|
||||
ok-text="ثبت و انتشار"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="saving"
|
||||
@ok="submitCreate"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="تصویر طرح" required>
|
||||
<ImageUploadInput v-model="form.imageUrl" purpose="product.survey" placeholder="بارگذاری تصویر" />
|
||||
</a-form-item>
|
||||
<a-form-item label="وزن پیشنهادی (گرم)" required>
|
||||
<a-input-number v-model:value="form.suggestedWeightGrams" :min="0.01" :step="0.1" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="عنوان (اختیاری)">
|
||||
<a-input v-model:value="form.title" :maxlength="200" allow-clear />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
type SurveyRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
imageUrl: string;
|
||||
suggestedWeightGrams: number;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
reachedMajority?: boolean;
|
||||
createdByCustomer: { id: string; fullName: string; mobile: string } | null;
|
||||
createdByAdmin: { id: string; fullName: string } | null;
|
||||
};
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const createOpen = ref(false);
|
||||
const rows = ref<SurveyRow[]>([]);
|
||||
const majorityHits = ref<SurveyRow[]>([]);
|
||||
const customerCount = ref(0);
|
||||
const majorityThreshold = ref(0);
|
||||
const statusFilter = ref<string | undefined>();
|
||||
const statusOptions = ref<{ value: string; label: string }[]>([]);
|
||||
|
||||
const columns = [
|
||||
{ title: 'تصویر', key: 'image', width: 88 },
|
||||
{ title: 'عنوان', key: 'title' },
|
||||
{ title: 'وزن پیشنهادی (گرم)', key: 'weight', width: 150 },
|
||||
{ title: 'رأی', key: 'votes', width: 150 },
|
||||
{ title: 'وضعیت', key: 'status', width: 130 },
|
||||
{ title: 'سازنده', key: 'creator' },
|
||||
{ title: 'عملیات', key: 'actions', width: 280 },
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
imageUrl: null as string | null,
|
||||
suggestedWeightGrams: null as number | null,
|
||||
title: '',
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!hasPermission('product.survey')) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
const q = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
|
||||
const res = await api.get(`admin/product-surveys${q}`);
|
||||
rows.value = res.data?.rows ?? [];
|
||||
majorityHits.value = res.data?.majorityHits ?? [];
|
||||
customerCount.value = Number(res.data?.customerCount ?? 0);
|
||||
majorityThreshold.value = Number(res.data?.majorityThreshold ?? 0);
|
||||
statusOptions.value = (res.data?.statusOptions ?? []).map((x: { value: string; label: string }) => ({
|
||||
value: x.value,
|
||||
label: x.label,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در بارگذاری');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.imageUrl = null;
|
||||
form.suggestedWeightGrams = null;
|
||||
form.title = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!form.imageUrl) {
|
||||
message.warning('تصویر الزامی است');
|
||||
return Promise.reject();
|
||||
}
|
||||
if (!form.suggestedWeightGrams || form.suggestedWeightGrams <= 0) {
|
||||
message.warning('وزن پیشنهادی را وارد کنید');
|
||||
return Promise.reject();
|
||||
}
|
||||
try {
|
||||
saving.value = true;
|
||||
await api.post('admin/product-surveys', {
|
||||
imageUrl: form.imageUrl,
|
||||
suggestedWeightGrams: form.suggestedWeightGrams,
|
||||
title: form.title || undefined,
|
||||
});
|
||||
message.success('طرح ثبت و منتشر شد');
|
||||
createOpen.value = false;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت');
|
||||
return Promise.reject();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function act(id: string, kind: 'publish' | 'close') {
|
||||
try {
|
||||
await api.post(`admin/product-surveys/${id}/${kind}`);
|
||||
message.success(kind === 'publish' ? 'منتشر شد' : 'رأیگیری بسته شد');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا');
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(id: string, decision: 'APPROVED' | 'REJECTED') {
|
||||
try {
|
||||
await api.post(`admin/product-surveys/${id}/decide`, { decision });
|
||||
message.success(decision === 'APPROVED' ? 'برای ساخت تأیید شد' : 'رد شد');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.survey-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.survey-page :deep(> .ant-card) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.survey-page :deep(> .ant-card .ant-card-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.survey-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
}
|
||||
.survey-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mb-4 {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
.vote {
|
||||
margin-inline-end: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vote.like {
|
||||
color: #389e0d;
|
||||
}
|
||||
.vote.dislike {
|
||||
color: #cf1322;
|
||||
}
|
||||
.vote-prog {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.majority-hit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.majority-hit__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ms-1 {
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,627 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="vre"
|
||||
:class="[`vre--${mode}`, { 'vre--invalid': hasErrors }]"
|
||||
role="group"
|
||||
:aria-label="mode === 'create' ? 'افزودن تنوع جدید' : 'ویرایش تنوع'"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<!-- required: what every weight variant needs -->
|
||||
<div class="vre__primary">
|
||||
<a-form-item
|
||||
class="vre__item"
|
||||
label="وزن"
|
||||
required
|
||||
:validate-status="errors.grams ? 'error' : ''"
|
||||
:help="errors.grams"
|
||||
>
|
||||
<a-input-number
|
||||
ref="gramsRef"
|
||||
v-model:value="draft.grams"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="0.001"
|
||||
addon-after="گرم"
|
||||
placeholder="مثلاً ۴٫۵"
|
||||
:formatter="gramsInput.formatter"
|
||||
:parser="gramsInput.parser"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
class="vre__item"
|
||||
label="اجرت درصدی"
|
||||
required
|
||||
:validate-status="errors.percentage_pay ? 'error' : ''"
|
||||
:help="errors.percentage_pay"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="draft.percentage_pay"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
addon-after="٪"
|
||||
placeholder="مثلاً ۱۸"
|
||||
:formatter="percentInput.formatter"
|
||||
:parser="percentInput.parser"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
class="vre__item"
|
||||
label="اجرت ریالی"
|
||||
required
|
||||
:validate-status="errors.rial_pay ? 'error' : ''"
|
||||
:help="errors.rial_pay"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="draft.rial_pay"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="50000"
|
||||
addon-after="ریال"
|
||||
placeholder="نداشت: صفر"
|
||||
:formatter="formatThousands"
|
||||
:parser="parseThousands"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- optional: collapsed until asked for -->
|
||||
<button
|
||||
type="button"
|
||||
class="vre__more-toggle"
|
||||
:class="{ 'is-open': moreOpen }"
|
||||
:aria-expanded="moreOpen"
|
||||
@click="moreOpen = !moreOpen"
|
||||
>
|
||||
<DownOutlined class="vre__more-chevron" />
|
||||
<span class="vre__more-title">موارد بیشتر</span>
|
||||
<span class="vre__more-hint">رنگ، تعداد، وضعیت فروش، تصویر و توضیحات</span>
|
||||
<span v-if="extraCount" class="vre__more-count">{{ faNumber(extraCount) }} مورد تنظیم شده</span>
|
||||
</button>
|
||||
|
||||
<div class="vre__more-wrap" :class="{ 'is-open': moreOpen }" :inert="!moreOpen || undefined">
|
||||
<div class="vre__more-inner">
|
||||
<div class="vre__more">
|
||||
<div class="vre__more-col">
|
||||
<div class="vre__pair">
|
||||
<a-form-item class="vre__item" label="رنگ طلا">
|
||||
<a-select v-model:value="draft.color" placeholder="انتخاب رنگ" allow-clear>
|
||||
<a-select-option v-for="c in colorOptions" :key="c.id" :value="c.id">
|
||||
<span class="vre-color">
|
||||
<span class="vre-color__dot" :class="`vre-color__dot--${c.id}`"></span>
|
||||
{{ c.title }}
|
||||
</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
class="vre__item"
|
||||
label="تعداد موجودی"
|
||||
:validate-status="errors.qty ? 'error' : ''"
|
||||
:help="errors.qty"
|
||||
:extra="!errors.qty && !draft.qty && !draft.is_pre_order ? 'خالی یا صفر: در اپ «ناموجود»' : undefined"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="draft.qty"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
addon-after="عدد"
|
||||
placeholder="اختیاری"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div class="vre__switches">
|
||||
<label class="vre__switch">
|
||||
<span>
|
||||
<b>فعال برای فروش</b>
|
||||
<small>{{ draft.is_active ? 'در اپ قابل سفارش است' : 'پنهان از مشتری' }}</small>
|
||||
</span>
|
||||
<a-switch v-model:checked="draft.is_active" size="small" />
|
||||
</label>
|
||||
<label class="vre__switch">
|
||||
<span>
|
||||
<b>پیشسفارش</b>
|
||||
<small>{{ draft.is_pre_order ? 'بدون موجودی هم سفارش میگیرد' : 'فقط با موجودی' }}</small>
|
||||
</span>
|
||||
<a-switch v-model:checked="draft.is_pre_order" size="small" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<a-form-item class="vre__item" label="توضیح مختصر">
|
||||
<a-input v-model:value="draft.details" placeholder="مثلاً: طراحی مات با تراش برلیان" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div class="vre__more-col">
|
||||
<a-form-item class="vre__item" label="تصویر اختصاصی تنوع">
|
||||
<div v-if="gallery.length" class="vre-picker">
|
||||
<button
|
||||
type="button"
|
||||
class="vre-picker__item vre-picker__item--none"
|
||||
:class="{ 'is-active': !draft.image_id }"
|
||||
@click="pickImage(null)"
|
||||
>
|
||||
<PictureOutlined />
|
||||
<span>بدون عکس</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="img in gallery"
|
||||
:key="img.id"
|
||||
type="button"
|
||||
class="vre-picker__item"
|
||||
:class="{ 'is-active': draft.image_id === img.id }"
|
||||
@click="pickImage(img)"
|
||||
>
|
||||
<img :src="img.url" alt="" />
|
||||
<span v-if="draft.image_id === img.id" class="vre-picker__check"><CheckOutlined /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="vre-picker-empty">{{ emptyGalleryText }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item class="vre__item" label="توضیحات کامل">
|
||||
<a-textarea
|
||||
v-model:value="draft.description"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
placeholder="ویژگیهای خاص این تنوع..."
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vre__actions">
|
||||
<a-button v-if="mode === 'edit' || dirty" @click="cancel">
|
||||
{{ mode === 'create' ? 'پاک کردن' : 'انصراف' }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="submit">
|
||||
<template #icon>
|
||||
<PlusOutlined v-if="mode === 'create'" />
|
||||
<CheckOutlined v-else />
|
||||
</template>
|
||||
{{ mode === 'create' ? 'افزودن ردیف' : 'ذخیره ردیف' }}
|
||||
</a-button>
|
||||
<span class="vre__keys">
|
||||
<kbd>Enter</kbd> {{ mode === 'create' ? 'افزودن' : 'ذخیره' }}
|
||||
<template v-if="mode === 'edit'"><kbd>Esc</kbd> انصراف</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { CheckOutlined, DownOutlined, PictureOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
autoTitle,
|
||||
composerDirty,
|
||||
decimalInput,
|
||||
extraFieldsFilled,
|
||||
faNumber,
|
||||
scrollFieldIntoView,
|
||||
validateVariant,
|
||||
type ColorOption,
|
||||
type GalleryImage,
|
||||
type VariantErrors,
|
||||
type VariantRow,
|
||||
} from './variant';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'create' | 'edit';
|
||||
colorOptions: ColorOption[];
|
||||
gallery: GalleryImage[];
|
||||
emptyGalleryText?: string;
|
||||
autofocus?: boolean;
|
||||
}>(),
|
||||
{ emptyGalleryText: 'تصویری در گالری محصول ثبت نشده است.', autofocus: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ submit: []; cancel: [] }>();
|
||||
|
||||
const draft = defineModel<VariantRow>({ required: true });
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
const gramsRef = ref<{ focus: () => void } | null>(null);
|
||||
const errors = reactive<VariantErrors>({});
|
||||
const moreOpen = ref(false);
|
||||
|
||||
const hasErrors = computed(() => Object.keys(errors).length > 0);
|
||||
const extraCount = computed(() => extraFieldsFilled(draft.value));
|
||||
const dirty = computed(() => composerDirty(draft.value));
|
||||
|
||||
// The title is the weight («۴٫۵ گرمی») — never typed by hand.
|
||||
const title = computed(() => autoTitle(draft.value.grams));
|
||||
watch(
|
||||
title,
|
||||
(t) => {
|
||||
draft.value.title = t;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Clear an error as soon as its field becomes valid.
|
||||
watch(
|
||||
draft,
|
||||
(v) => {
|
||||
if (!hasErrors.value) return;
|
||||
const now = validateVariant(v);
|
||||
(Object.keys(errors) as (keyof VariantErrors)[]).forEach((k) => {
|
||||
if (!now[k]) delete errors[k];
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function pickImage(img: GalleryImage | null) {
|
||||
draft.value.image_id = img ? img.id : null;
|
||||
draft.value.image_url = img ? img.url : null;
|
||||
}
|
||||
|
||||
const gramsInput = decimalInput(3);
|
||||
const percentInput = decimalInput(2);
|
||||
|
||||
const formatThousands = (val: unknown) =>
|
||||
val === null || val === undefined || val === '' ? '' : String(val).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
const parseThousands = (val: string) => (val ? val.replace(/,/g, '') : '');
|
||||
|
||||
/** Paint field errors, open «موارد بیشتر» if needed, and land on the first one. */
|
||||
async function validateAndScroll(): Promise<boolean> {
|
||||
await nextTick();
|
||||
const found = validateVariant(draft.value);
|
||||
(Object.keys(errors) as (keyof VariantErrors)[]).forEach((k) => delete errors[k]);
|
||||
Object.assign(errors, found);
|
||||
if (!Object.keys(found).length) return true;
|
||||
if (found.qty) moreOpen.value = true;
|
||||
await nextTick();
|
||||
scrollFieldIntoView(rootRef.value?.querySelector('.ant-form-item-has-error'));
|
||||
return false;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!(await validateAndScroll())) return;
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
// dropdowns and multi-line text own their Enter/Escape
|
||||
if (!target || target.closest('.ant-select') || target.tagName === 'TEXTAREA') return;
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === 'Escape' && props.mode === 'edit') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to a clean slate after the parent swapped in the next row's draft. */
|
||||
function reset() {
|
||||
(Object.keys(errors) as (keyof VariantErrors)[]).forEach((k) => delete errors[k]);
|
||||
draft.value.title = title.value;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
gramsRef.value?.focus();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
moreOpen.value = props.mode === 'edit' && extraCount.value > 0;
|
||||
if (props.autofocus) nextTick(focus);
|
||||
});
|
||||
|
||||
defineExpose({ focus, reset, validateAndScroll });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vre {
|
||||
--vre-primary: #1c2d56;
|
||||
--vre-line: #e4e8f0;
|
||||
--vre-muted: #8a94a6;
|
||||
position: relative;
|
||||
padding: 14px 16px 12px;
|
||||
border: 1px dashed #c9d1e0;
|
||||
border-radius: 14px;
|
||||
background: #fafbfd;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.vre:focus-within {
|
||||
border-style: solid;
|
||||
border-color: var(--vre-primary);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(28, 45, 86, 0.08);
|
||||
}
|
||||
.vre--edit {
|
||||
border-style: solid;
|
||||
border-color: #b9c3d8;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 20px -12px rgba(16, 24, 40, 0.25);
|
||||
}
|
||||
.vre--invalid {
|
||||
border-color: #ff7875;
|
||||
}
|
||||
|
||||
.vre__keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--vre-muted);
|
||||
}
|
||||
.vre__keys kbd {
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--vre-line);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
line-height: 16px;
|
||||
color: #475467;
|
||||
}
|
||||
.vre__keys kbd:not(:first-child) {
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
.vre__primary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 10px;
|
||||
}
|
||||
.vre__pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 10px;
|
||||
}
|
||||
.vre__item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vre__item :deep(.ant-form-item-label) {
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.vre__item :deep(.ant-form-item-label > label) {
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #475467;
|
||||
}
|
||||
.vre__item :deep(.ant-form-item-explain) {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* «موارد بیشتر» */
|
||||
.vre__more-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--vre-primary);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.vre__more-toggle:hover,
|
||||
.vre__more-toggle:focus-visible {
|
||||
background: rgba(28, 45, 86, 0.05);
|
||||
outline: none;
|
||||
}
|
||||
.vre__more-chevron {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.vre__more-toggle.is-open .vre__more-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.vre__more-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.vre__more-hint {
|
||||
color: var(--vre-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.vre__more-count {
|
||||
margin-inline-start: auto;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
color: #1d6b40;
|
||||
background: #e8f6ee;
|
||||
}
|
||||
|
||||
/* height animates via grid rows, no JS measuring */
|
||||
.vre__more-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
.vre__more-wrap.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.vre__more-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vre__more {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
margin-top: 8px;
|
||||
padding: 12px 12px 2px;
|
||||
border: 1px solid var(--vre-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vre__switches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vre__switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--vre-line);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vre__switch:hover {
|
||||
border-color: #c9d1e0;
|
||||
}
|
||||
.vre__switch span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.vre__switch b {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
}
|
||||
.vre__switch small {
|
||||
font-size: 10.5px;
|
||||
color: var(--vre-muted);
|
||||
}
|
||||
|
||||
.vre-color {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.vre-color__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.vre-color__dot--0 { background: #fdfdfd; }
|
||||
.vre-color__dot--1 { background: #ffd700; }
|
||||
.vre-color__dot--2 { background: #795548; }
|
||||
.vre-color__dot--3 { background: #b76e79; }
|
||||
|
||||
.vre-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 128px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
.vre-picker__item {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
padding: 2px;
|
||||
border: 1.5px solid var(--vre-line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.vre-picker__item:hover {
|
||||
border-color: var(--vre-primary);
|
||||
}
|
||||
.vre-picker__item.is-active {
|
||||
border-color: var(--vre-primary);
|
||||
box-shadow: 0 0 0 2px rgba(28, 45, 86, 0.18);
|
||||
}
|
||||
.vre-picker__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
}
|
||||
.vre-picker__item--none {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.vre-picker__check {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
font-size: 8px;
|
||||
color: #fff;
|
||||
background: var(--vre-primary);
|
||||
}
|
||||
.vre-picker-empty {
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--vre-line);
|
||||
border-radius: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--vre-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vre__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.vre__actions .ant-btn {
|
||||
min-width: 96px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vre__primary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.vre__primary > .vre__item:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.vre__more {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.vre__keys,
|
||||
.vre__more-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.vre__switches {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.vre__actions .ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
export interface VariantRow {
|
||||
__key: string;
|
||||
id?: string;
|
||||
title: string;
|
||||
grams?: number | null;
|
||||
percentage_pay?: number | null;
|
||||
qty?: number | null;
|
||||
is_active: boolean;
|
||||
is_pre_order: boolean;
|
||||
color?: number | null;
|
||||
color_str?: string | null;
|
||||
rial_pay?: number | null;
|
||||
details?: string | null;
|
||||
description?: string | null;
|
||||
image_id?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
id: string;
|
||||
url: string;
|
||||
role: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ColorOption {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type VariantErrors = Partial<Record<'grams' | 'qty' | 'percentage_pay' | 'rial_pay', string>>;
|
||||
|
||||
export function newVariantKey(): string {
|
||||
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function emptyVariant(defaultWage?: number): VariantRow {
|
||||
return {
|
||||
__key: newVariantKey(),
|
||||
id: undefined,
|
||||
title: '',
|
||||
grams: null,
|
||||
percentage_pay: defaultWage ?? null,
|
||||
qty: null,
|
||||
is_active: true,
|
||||
is_pre_order: false,
|
||||
color: null,
|
||||
color_str: null,
|
||||
rial_pay: null,
|
||||
details: '',
|
||||
description: '',
|
||||
image_id: null,
|
||||
image_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const faNumber = (n: number, maxFraction = 3) =>
|
||||
Number(n).toLocaleString('fa-IR', { maximumFractionDigits: maxFraction });
|
||||
|
||||
/**
|
||||
* Scroll the page's scroll container (`#content-area`) so `el` sits in the
|
||||
* middle of the viewport, then focus its first control without a second jump.
|
||||
* Prefer a form-item (or the first `.ant-form-item-has-error` under a root).
|
||||
*/
|
||||
export function scrollFieldIntoView(el: Element | null | undefined) {
|
||||
if (!el || typeof (el as HTMLElement).scrollIntoView !== 'function') return;
|
||||
const target = (el.closest?.('.ant-form-item') as HTMLElement | null) ?? (el as HTMLElement);
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
||||
const focusable = target.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), .ant-select-selector, .ant-input-number-input, button:not([disabled])',
|
||||
);
|
||||
window.setTimeout(() => focusable?.focus?.({ preventScroll: true }), 280);
|
||||
}
|
||||
|
||||
const toAsciiDigits = (s: string) =>
|
||||
s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٫٬,]/g, (c) => (c === '٫' ? '.' : ''));
|
||||
|
||||
/**
|
||||
* InputNumber formatter/parser pair for decimals: shows exactly what was typed
|
||||
* (no `step`-driven zero padding like «5.100»), capped at `maxFraction` digits,
|
||||
* and accepts Persian digits and «٫».
|
||||
*/
|
||||
export function decimalInput(maxFraction: number) {
|
||||
return {
|
||||
formatter: (val: unknown, info?: { userTyping: boolean; input: string }) => {
|
||||
if (info?.userTyping) return info.input;
|
||||
if (val === '' || val === null || val === undefined) return '';
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? String(Number(n.toFixed(maxFraction))) : String(val);
|
||||
},
|
||||
parser: (val: string) => {
|
||||
const m = toAsciiDigits(val ?? '')
|
||||
.replace(/[^\d.]/g, '')
|
||||
.match(/^(\d*)(?:\.(\d*))?/);
|
||||
if (!m) return '';
|
||||
return m[2] !== undefined ? `${m[1]}.${m[2].slice(0, maxFraction)}` : m[1];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** «۵ گرمی» — a variant's title is always derived from its weight. */
|
||||
export function autoTitle(grams?: number | null): string {
|
||||
return grams != null && grams > 0 ? `${faNumber(grams)} گرمی` : '';
|
||||
}
|
||||
|
||||
const isBlank = (v: unknown) => v === null || v === undefined || v === '';
|
||||
|
||||
export function validateVariant(v: VariantRow): VariantErrors {
|
||||
const e: VariantErrors = {};
|
||||
if (isBlank(v.grams)) e.grams = 'وزن را وارد کنید';
|
||||
else if (Number(v.grams) <= 0) e.grams = 'وزن باید بیشتر از صفر باشد';
|
||||
if (!isBlank(v.qty) && Number(v.qty) < 0) e.qty = 'تعداد منفی نمیشود';
|
||||
if (isBlank(v.percentage_pay)) e.percentage_pay = 'اجرت درصدی را وارد کنید';
|
||||
if (isBlank(v.rial_pay)) e.rial_pay = 'اجرت ریالی را وارد کنید؛ اگر ندارد صفر بزنید';
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Fields behind «موارد بیشتر» that differ from a fresh row. */
|
||||
export function extraFieldsFilled(v: VariantRow): number {
|
||||
return [
|
||||
v.color != null,
|
||||
!isBlank(v.qty),
|
||||
Boolean(v.image_id),
|
||||
Boolean(v.details),
|
||||
Boolean(v.description),
|
||||
!v.is_active,
|
||||
v.is_pre_order,
|
||||
].filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** The composer holds something the operator would lose by navigating away. */
|
||||
export function composerDirty(v: VariantRow): boolean {
|
||||
return Boolean(
|
||||
v.title?.trim() || !isBlank(v.grams) || !isBlank(v.qty) || v.image_id || v.details || v.description,
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
show-icon
|
||||
class="mb-4"
|
||||
message="سینک مشتری اجرا نمیشود"
|
||||
description="هیچ گروه مشتری برای ورود انتخاب نشده است؛ در تب «نگاشت» گروههایی را که واقعاً مشتری هستند انتخاب و ذخیره کنید."
|
||||
description="در تب «نگاشت» مشخص کنید کدام گروههای حسابداری مشتریاند؛ اگر خالی باشد کسی وصل نمیشود."
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
@@ -139,21 +139,21 @@
|
||||
<ol>
|
||||
<li>تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروهها از حسابداری» را بزنید تا فهرستها از حسابداری پر شوند.</li>
|
||||
<li>
|
||||
<b>گروههای حسابی که مشتری {{ brand.name }} حساب میشوند:</b> در حسابداری صندوق، کارمند و سرمایهگذار هم «حساب» هستند. فقط گروههایی را انتخاب کنید که
|
||||
واقعاً مشتریاند؛ سینک مشتری فقط حسابهای این گروهها را با شماره موبایل به کاربران اپ مشتری وصل میکند. اگر خالی بماند سینک مشتری اجرا نمیشود.
|
||||
<b>کدام گروههای حسابداری مشتریاند؟</b> اینها گروههای خودِ نرمافزار حسابداریاند (نه {{ brand.name }}). صندوق، کارمند و سرمایهگذار را نزنید؛
|
||||
فقط گروههای واقعی مشتری. سینک فقط همینها را با موبایل به کاربران اپ وصل میکند. خالی = سینک مشتری اجرا نمیشود.
|
||||
</li>
|
||||
<li>
|
||||
<b>گروه مشتری جدید:</b> وقتی کاربری در اپ مشتری احراز هویت شود و در حسابداری حساب نداشته باشد، حسابش در این گروه ساخته میشود.
|
||||
اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» میماند.
|
||||
<b>حساب جدید اپ در کدام گروه ساخته شود؟</b> اگر کسی در اپ مشتری احراز هویت کند و در حسابداری هنوز حساب نداشته باشد، حسابش اینجا ساخته میشود.
|
||||
خالی = ساخت حساب در «لاگ ارسال» میماند تا خودتان تعیین کنید.
|
||||
</li>
|
||||
<li>
|
||||
<b>عیار پایه:</b> عیار وزنهایی که در {{ brand.name }} ثبت میشود (وزن محصولها و سفارشها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
|
||||
حسابداری میرود؛ در تهحساب اجرت هم روی آن اعمال میشود (مثلاً ۷۵۰ با اجرت ۱۰٪ میشود ۸۲۵).
|
||||
</li>
|
||||
<li v-if="form.provider === 'zarrin'">
|
||||
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته میشود. «نوع» تعیین میکند
|
||||
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دستهبندی محصولِ {{ brand.name }} میتوانید گروه جدا بدهید؛ دستهای که
|
||||
«پیشفرض» بماند در «گروه ساختهی پیشفرض» میرود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی میماند.
|
||||
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته میشود.
|
||||
نوع (گرمی/تعدادی) و گروه ساخته را اینجا مشخص کنید. در جدول برای هر دسته میتوانید گروه جدا بدهید؛ بقیه به گروه پیشفرض میروند.
|
||||
اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی میماند.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="hint">
|
||||
@@ -167,25 +167,23 @@
|
||||
</a-space>
|
||||
<a-form layout="vertical">
|
||||
<a-divider orientation="right">حساب مشتریها</a-divider>
|
||||
<a-alert type="info" show-icon class="mb-4" :message="`این بخش تعیین میکند کدام حسابهای حسابداری به کاربران اپ مشتری ${brand.name} وصل شوند`">
|
||||
<a-alert type="info" show-icon class="mb-4" message="اینها گروههای نرمافزار حسابداریاند؛ مشخص میکنند چه کسی مشتری است و حساب تازه کجا ساخته شود">
|
||||
<template #description>
|
||||
<div>
|
||||
هر حسابی که در گروههای انتخابشده باشد و موبایلش با یک کاربر اپ مشتری {{ brand.name }} یکی باشد، به آن کاربر وصل میشود و ماندهاش در اپ مشتری
|
||||
نمایش داده میشود. صندوق، کارمند و سرمایهگذار را انتخاب نکنید.
|
||||
فیلد اول: حسابهای همین گروهها (با موبایل یکسان) به کاربر اپ وصل میشوند و ماندهشان در اپ دیده میشود. صندوق، کارمند و سرمایهگذار را نزنید.
|
||||
</div>
|
||||
<div>
|
||||
مثال: «بنکدار» و «مغازهدار» را در فیلد اول انتخاب کنید و در فیلد دوم «مغازهدار» را بگذارید. در این حالت کسی که در اپ مشتری ثبتنام و
|
||||
احراز هویت کند و در حسابداری حساب نداشته باشد، در گروه «مغازهدار» برایش حساب ساخته میشود.
|
||||
فیلد دوم: اگر کاربر اپ هنوز در حسابداری حساب ندارد، آنجا ساخته میشود. مثال: اولی «بنکدار» و «مغازهدار»، دومی «مغازهدار».
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<div class="grid-2">
|
||||
<a-form-item :label="`گروههای حسابی که مشتری ${brand.name} حساب میشوند`" extra="فقط گروههایی که مشتریاند؛ خالی = سینک مشتری اجرا نمیشود">
|
||||
<a-form-item label="کدام گروههای حسابداری مشتریاند؟" extra="فقط گروههای واقعی مشتری؛ خالی = سینک مشتری اجرا نمیشود">
|
||||
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="هیچ گروهی انتخاب نشده" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="گروه حسابداریِ مشتری تازهی اپ مشتری"
|
||||
extra="کاربری که در اپ مشتری احراز هویت شده و در حسابداری حساب ندارد، در این گروه برایش حساب ساخته میشود"
|
||||
label="حساب جدید اپ در کدام گروه ساخته شود؟"
|
||||
extra="اگر کاربر اپ در حسابداری حساب ندارد، اینجا ساخته میشود؛ خالی = منتظر بررسی میماند"
|
||||
>
|
||||
<a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear />
|
||||
</a-form-item>
|
||||
@@ -205,7 +203,7 @@
|
||||
<div>
|
||||
ستون «دستهبندی محصول در {{ brand.name }}» همان دستهبندیهایی است که در پنل مدیریت برای محصولات تعریف کردهاید. مثال: اگر روبهروی «انگشتر»
|
||||
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دستهی انگشتر {{ brand.name }}، در گروه انگشترِ زرین ساخته میشود.
|
||||
دستههایی که «پیشفرض» بمانند در «گروه ساختهی پیشفرض» میروند.
|
||||
دستههایی که در جدول «پیشفرض» بمانند، به همان گروه پیشفرض پایین میروند.
|
||||
</div>
|
||||
<div>
|
||||
اگر نوع یا گروه خالی باشد، محصول در {{ brand.name }} ساخته میشود ولی ساخت کارساخته در «لاگ ارسال» منتظر میماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
|
||||
@@ -214,10 +212,10 @@
|
||||
</template>
|
||||
</a-alert>
|
||||
<div class="grid-2">
|
||||
<a-form-item label="نوع کارساختهی جدید (گرمی/تعدادی…)" :extra="`برای محصول تازهای که در پنل مدیریت ${brand.name} تعریف میشود`">
|
||||
<a-form-item label="محصول جدید با چه نوعی در زرین ساخته شود؟" extra="مثلاً گرمی یا تعدادی؛ برای هر محصول تازهی پنل">
|
||||
<a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="گروه ساختهی پیشفرض" extra="برای دستههایی که در جدول زیر «پیشفرض» ماندهاند">
|
||||
<a-form-item label="اگر برای دسته گروه نگذاشتید، کجا برود؟" extra="دستههایی که در جدول زیر «پیشفرض» ماندهاند، اینجا ساخته میشوند">
|
||||
<a-select v-model:value="newItemGroupKey" :options="itemGroupOptions" allow-clear />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<a-card v-if="hasPermission('config.cart')" title="انقضای سبد خرید اپ" class="page-card">
|
||||
<a-spin :spinning="loading" tip="لطفا کمی تامل کنید">
|
||||
<a-form layout="vertical" @submit.prevent="save">
|
||||
<a-form-item label="فعالسازی خالیشدن روزانه">
|
||||
<a-switch v-model:checked="form.enabled" />
|
||||
<div class="hint">
|
||||
خاموش = رفتار فعلی (بدون کرون و بدون پیامک). سبد مهمان فقط در کلاینت پاک میشود.
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<div class="grid-2">
|
||||
<a-form-item label="ساعت خالیشدن (تهران)">
|
||||
<a-time-picker
|
||||
v-model:value="clearAt"
|
||||
class="w-full"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
:show-now="false"
|
||||
:allow-clear="false"
|
||||
placeholder="انتخاب ساعت"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="شروع پنجرهٔ تعویق (تهران)">
|
||||
<a-time-picker
|
||||
v-model:value="deferFrom"
|
||||
class="w-full"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
:show-now="false"
|
||||
:allow-clear="false"
|
||||
placeholder="انتخاب ساعت"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="hint" style="margin-bottom: 1rem">
|
||||
اگر بین شروع تعویق تا ساعت خالیشدن چیزی به سبد اضافه شود، انقضا میرود روی
|
||||
<b>خالیشدن فردا</b>
|
||||
(پیشفرض: ۰۸:۰۰ تا ۱۳:۰۰ → ۱۳:۰۰ فردا). قبل از این پنجره همانروز خالی میشود.
|
||||
</div>
|
||||
|
||||
<a-form-item label="پیامک یادآوری">
|
||||
<a-switch v-model:checked="form.reminderEnabled" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="چند ساعت قبل از خالیشدن">
|
||||
<a-input-number
|
||||
v-model:value="form.reminderHoursBefore"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="48"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="متن پیامک">
|
||||
<a-textarea
|
||||
v-model:value="form.reminderBody"
|
||||
:rows="3"
|
||||
placeholder="از %count برای تعداد ساعت تا خالیشدن استفاده کنید"
|
||||
/>
|
||||
<div class="hint">جایگزین `%count` با تعداد ساعت قبل از خالیشدن.</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-button type="primary" class="form-submit" :loading="saving" @click="save">
|
||||
ذخیره
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import api from '../../api/client';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
/** `HH:mm` strings for a-time-picker with value-format. */
|
||||
const clearAt = ref('13:00');
|
||||
const deferFrom = ref('08:00');
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
reminderEnabled: true,
|
||||
reminderHoursBefore: 2,
|
||||
reminderBody:
|
||||
'سبد خرید شما تا %count ساعت دیگر خالی میشود. برای ثبت سفارش همین امروز اقدام کنید.',
|
||||
});
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(Math.max(0, Math.min(59, Math.trunc(n)))).padStart(2, '0');
|
||||
}
|
||||
|
||||
function toHm(hour: number, minute: number) {
|
||||
return `${pad2(Math.max(0, Math.min(23, Math.trunc(hour))))}:${pad2(minute)}`;
|
||||
}
|
||||
|
||||
function fromHm(value: string | null | undefined, fallbackHour: number, fallbackMinute: number) {
|
||||
const m = /^(\d{1,2}):(\d{1,2})$/.exec((value ?? '').trim());
|
||||
if (!m) return { hour: fallbackHour, minute: fallbackMinute };
|
||||
return {
|
||||
hour: Math.max(0, Math.min(23, Number(m[1]))),
|
||||
minute: Math.max(0, Math.min(59, Number(m[2]))),
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await api.get('admin/cart-expiry/config');
|
||||
const data = res.data ?? {};
|
||||
form.enabled = data.enabled === true;
|
||||
form.reminderEnabled = data.reminderEnabled !== false;
|
||||
form.reminderHoursBefore = Number(data.reminderHoursBefore ?? 2);
|
||||
if (typeof data.reminderBody === 'string' && data.reminderBody.trim()) {
|
||||
form.reminderBody = data.reminderBody;
|
||||
}
|
||||
clearAt.value = toHm(Number(data.clearHour ?? 13), Number(data.clearMinute ?? 0));
|
||||
deferFrom.value = toHm(
|
||||
Number(data.sameDayDeferFromHour ?? 8),
|
||||
Number(data.sameDayDeferFromMinute ?? 0),
|
||||
);
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در بارگذاری تنظیمات');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
saving.value = true;
|
||||
const clear = fromHm(clearAt.value, 13, 0);
|
||||
const defer = fromHm(deferFrom.value, 8, 0);
|
||||
await api.put('admin/cart-expiry/config', {
|
||||
enabled: form.enabled,
|
||||
clearHour: clear.hour,
|
||||
clearMinute: clear.minute,
|
||||
sameDayDeferFromHour: defer.hour,
|
||||
sameDayDeferFromMinute: defer.minute,
|
||||
reminderEnabled: form.reminderEnabled,
|
||||
reminderHoursBefore: form.reminderHoursBefore,
|
||||
reminderBody: form.reminderBody,
|
||||
});
|
||||
message.success('تغییرات ثبت شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
margin-top: 0.35rem;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-submit {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,91 +1,339 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/statics/index.vue — the customer
|
||||
activity log ("آمار"). Pick a customer, see their recent actions.
|
||||
-->
|
||||
<a-spin :spinning="loading">
|
||||
<a-card title="آمار" class="page-card table-scroll">
|
||||
<a-form layout="vertical">
|
||||
<div class="grid-2">
|
||||
<a-form-item label="مشتری">
|
||||
<a-select
|
||||
v-model:value="customer"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="انتخاب کنید"
|
||||
:filter-option="filterOption"
|
||||
@change="load"
|
||||
>
|
||||
<a-select-option v-for="c in customers" :key="c.id" :value="c.id">
|
||||
{{ c.name }} - {{ c.mobile }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
<!-- 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-table
|
||||
:data-source="logs"
|
||||
:columns="columns"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
size="small"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'caj'">
|
||||
{{ jdateTime(record.caj) }}
|
||||
<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>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</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 { optionText } from '../../utils/select';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { jdateTime } from '../../utils/filters';
|
||||
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';
|
||||
|
||||
const loading = ref(false);
|
||||
const customers = ref<{ id: string; name: string; mobile: string }[]>([]);
|
||||
const customer = ref<string | undefined>(undefined);
|
||||
const logs = ref<Record<string, any>[]>([]);
|
||||
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 columns = [
|
||||
{ title: 'عملیات', dataIndex: 'action' },
|
||||
{ title: 'جزئیات', dataIndex: 'detail' },
|
||||
{ title: 'تاریخ', dataIndex: 'caj' },
|
||||
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 },
|
||||
];
|
||||
|
||||
function filterOption(input: string, option: any) {
|
||||
return optionText(option).includes(input);
|
||||
}
|
||||
const viewsEmptyText = computed(() =>
|
||||
top.value.viewsTrackedSince
|
||||
? `در ${periodLabel.value} بازدیدی ثبت نشده است.`
|
||||
: 'هنوز بازدیدی ثبت نشده. هر بار که مشتری صفحهٔ یک محصول را در اپ باز کند شمرده میشود.',
|
||||
);
|
||||
|
||||
async function load() {
|
||||
if (!customer.value) {
|
||||
logs.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
const { data } = await api.get(`log/ago/${customer.value}`);
|
||||
logs.value = data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('log/list/customer');
|
||||
customers.value = data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
/** 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>
|
||||
|
||||
<!-- .grid-2 now comes from the global utilities.less (dedup) -->
|
||||
<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>
|
||||
|
||||
@@ -838,6 +838,10 @@ export const treeData: PermissionNode[] = [
|
||||
"title": "مدیریت کیف پول",
|
||||
"key": "config.wallet",
|
||||
},
|
||||
{
|
||||
"title": "انقضای سبد خرید",
|
||||
"key": "config.cart",
|
||||
},
|
||||
{
|
||||
"title": "گزارش",
|
||||
"key": "config.report",
|
||||
|
||||
Reference in New Issue
Block a user