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:
mahmood
2026-11-12 17:55:52 +03:30
co-authored by Cursor
parent 032fa800d9
commit 7dd74ff4f7
74 changed files with 6351 additions and 893 deletions
+1
View File
@@ -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;
+17 -1
View File
@@ -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',
+48 -2
View File
@@ -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'
}
}
+48
View File
@@ -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>
+324 -76
View File
@@ -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",