feat(products): port missing variant fields from henza and redesign variant modal

This commit is contained in:
mahmood
2026-10-25 16:06:17 +03:30
parent 143d9aefbb
commit 55d27151c9
236 changed files with 1009 additions and 352 deletions
@@ -160,10 +160,18 @@
<span v-if="record.grams != null">{{ record.grams }} <small class="muted">گرم</small></span>
<span v-else class="muted">—</span>
</template>
<template v-else-if="column.dataIndex === 'color'">
<span v-if="record.color_str">{{ record.color_str }}</span>
<span v-else class="muted">—</span>
</template>
<template v-else-if="column.dataIndex === 'percentage_pay'">
<span v-if="record.percentage_pay != null">{{ record.percentage_pay }}<small class="muted">٪</small></span>
<span v-else class="muted">—</span>
</template>
<template v-else-if="column.dataIndex === 'rial_pay'">
<span v-if="record.rial_pay != null">{{ Number(record.rial_pay).toLocaleString('fa-IR') }} <small class="muted">ریال</small></span>
<span v-else class="muted">—</span>
</template>
<template v-else-if="column.dataIndex === 'op'">
<div class="pf-table__ops">
<a-tooltip v-if="hasPermission('product.list.variants.edit')" title="ویرایش">
@@ -319,82 +327,164 @@
</a-form>
</a-spin>
<!-- variant add/edit -->
<!-- variant add/edit modal (Compact Widescreen Layout - No Page Scroll) -->
<a-modal
v-model:open="variantModal"
:title="editingIndex === null ? 'افزودن تنوع' : 'ویرایش تنوع'"
:width="640"
:width="860"
wrap-class-name="pf-variant-modal"
:footer="null"
:destroy-on-close="true"
centered
>
<a-form layout="vertical" class="pf-form variant-form">
<!--
تصویر تنوع — one image, and it must be one of THIS product's images:
an upload here is also added to the site gallery, or an existing
gallery image can be reused so one photo backs several weights.
Unsaved images have no id yet, so the variant carries the URL and the
API resolves it after saving the images.
-->
<div class="variant-form__media">
<!-- انتخاب تصویر تنوع (کاملاً طبق توافق PROTECTED §B) -->
<a-form-item label="تصویر اختصاصی این تنوع" class="variant-image-item">
<div v-if="gallery.length" class="variant-picker">
<button
type="button"
class="variant-picker__item"
:class="{ 'is-on': !variantForm.image_id }"
title="بدون تصویر اختصاصی"
@click="pickVariantImage(null)"
>
<span class="variant-picker__none">بدون تصویر</span>
</button>
<button
v-for="img in gallery"
:key="img.id"
type="button"
class="variant-picker__item"
:class="{ 'is-on': variantForm.image_id === img.id }"
@click="pickVariantImage(img)"
>
<img :src="img.url" alt="" />
</button>
<template #title>
<div class="vmodal-header">
<div class="vmodal-header__icon">
<AppstoreOutlined />
</div>
<div class="vmodal-header__text">
<h3>{{ editingIndex === null ? 'افزودن تنوع جدید' : 'ویرایش تنوع' }}</h3>
<p>مشخصات، قیمت‌گذاری و تصویر این تنوع را وارد کنید.</p>
</div>
</div>
</template>
<a-form layout="vertical" class="vmodal-form">
<div class="vmodal-cols">
<!-- ── ستون راست: مشخصات اصلی و قیمت ── -->
<div class="vmodal-col">
<div class="vmodal-card">
<div class="vmodal-card__head">مشخصات اصلی</div>
<div class="vmodal-grid vmodal-grid--2">
<a-form-item label="عنوان تنوع" required class="vmodal-item">
<a-input v-model:value="variantForm.title" placeholder="مثلاً: ۵ گرمی" />
</a-form-item>
<a-form-item label="وزن خام" class="vmodal-item">
<a-input-number
v-model:value="variantForm.grams"
class="w-full"
:step="0.001"
addon-after="گرم"
placeholder="۰.۰۰۰"
/>
</a-form-item>
</div>
<div class="vmodal-grid vmodal-grid--2">
<a-form-item label="تعداد موجودی" class="vmodal-item">
<a-input-number v-model:value="variantForm.qty" class="w-full" :min="0" placeholder="۰" />
</a-form-item>
<a-form-item label="رنگ طلا" class="vmodal-item">
<a-select v-model:value="variantForm.color" placeholder="انتخاب رنگ" allow-clear>
<a-select-option v-for="c in colorOptions" :key="c.id" :value="c.id">
<div class="color-opt">
<span class="color-opt__dot" :class="`color-opt__dot--${c.id}`"></span>
<span>{{ c.title }}</span>
</div>
</a-select-option>
</a-select>
</a-form-item>
</div>
<div class="vmodal-switches">
<label class="vmodal-switch-box">
<span class="vmodal-switch-label">فعال برای فروش</span>
<a-switch v-model:checked="variantForm.is_active" size="small" />
</label>
<label class="vmodal-switch-box">
<span class="vmodal-switch-label">پیش‌سفارش</span>
<a-switch v-model:checked="variantForm.is_pre_order" size="small" />
</label>
</div>
</div>
<div class="vmodal-card">
<div class="vmodal-card__head">اجرت و قیمت‌گذاری</div>
<div class="vmodal-grid vmodal-grid--2">
<a-form-item label="اجرت درصدی" class="vmodal-item">
<a-input-number
v-model:value="variantForm.percentage_pay"
class="w-full"
:step="0.1"
addon-after="٪"
placeholder="۰"
/>
</a-form-item>
<a-form-item label="اجرت ریالی" class="vmodal-item">
<a-input-number
v-model:value="variantForm.rial_pay"
class="w-full"
:step="50000"
addon-after="ریال"
placeholder="۰"
:formatter="(val: any) => val ? `${val}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''"
:parser="(val: any) => val ? val.replace(/,/g, '') : ''"
/>
</a-form-item>
</div>
</div>
</div>
<!-- ── ستون چپ: تصویر و توضیحات ── -->
<div class="vmodal-col">
<div class="vmodal-card">
<div class="vmodal-card__head">تصویر اختصاصی تنوع</div>
<div v-if="gallery.length" class="vmodal-picker">
<button
type="button"
class="vmodal-picker__item"
:class="{ 'is-active': !variantForm.image_id }"
title="بدون تصویر"
@click="pickVariantImage(null)"
>
<PictureOutlined />
<span>بدون عکس</span>
</button>
<button
v-for="img in gallery"
:key="img.id"
type="button"
class="vmodal-picker__item"
:class="{ 'is-active': variantForm.image_id === img.id }"
@click="pickVariantImage(img)"
>
<img :src="img.url" alt="" />
<div v-if="variantForm.image_id === img.id" class="vmodal-picker__check">
<CheckOutlined />
</div>
</button>
</div>
<div v-else class="vmodal-picker-empty">
تصویری در گالری ثبت نشده است (می‌توانید در بخش گالری محصول تصویر بارگذاری کنید).
</div>
</div>
<div class="vmodal-card">
<div class="vmodal-card__head">توضیحات تکمیلی</div>
<a-form-item label="توضیح مختصر" class="vmodal-item">
<a-input v-model:value="variantForm.details" placeholder="مثلاً: طراحی مات با تراش برلیان" />
</a-form-item>
<a-form-item label="توضیحات کامل" class="vmodal-item mb-0">
<a-textarea
v-model:value="variantForm.description"
style="height: 95px; min-height: 95px; max-height: 95px; resize: none;"
placeholder="ویژگی‌های خاص این تنوع..."
/>
</a-form-item>
</div>
</div>
<a-alert
v-else
type="info"
show-icon
:message="id ? 'برای این محصول تصویری در گالری ثبت نشده است.' : 'ابتدا محصول را ذخیره کنید تا تصاویر گالری برای انتخاب فعال شوند.'"
/>
</a-form-item>
</div>
<div class="variant-form__fields">
<a-form-item label="عنوان" required>
<a-input v-model:value="variantForm.title" placeholder="مثلاً: ۵ گرمی" />
</a-form-item>
<div class="pf-grid-3">
<a-form-item label="وزن">
<a-input-number v-model:value="variantForm.grams" class="w-full" :step="0.001" addon-after="گرم" />
</a-form-item>
<a-form-item label="اجرت درصدی">
<a-input-number v-model:value="variantForm.percentage_pay" class="w-full" :step="0.1" addon-after="٪" />
</a-form-item>
<a-form-item label="تعداد">
<a-input-number v-model:value="variantForm.qty" class="w-full" />
</a-form-item>
</div>
<a-form-item label="وضعیت" class="mb-0">
<a-space :size="24">
<a-checkbox v-model:checked="variantForm.is_active">فعال</a-checkbox>
<a-checkbox v-model:checked="variantForm.is_pre_order">پیش سفارش</a-checkbox>
</a-space>
</a-form-item>
<!-- ── دکمه‌های فوتر ── -->
<div class="vmodal-footer">
<a-button class="vmodal-footer__cancel" @click="variantModal = false">
انصراف
</a-button>
<a-button type="primary" class="vmodal-footer__submit" @click="saveVariant">
<template #icon><CheckOutlined /></template>
{{ editingIndex === null ? 'افزودن تنوع' : 'ذخیره تغییرات' }}
</a-button>
</div>
</a-form>
<p v-if="users.isSuperAdmin" class="variant-form__hint">تصویر بارگذاری‌شده به گالری سایت محصول هم اضافه می‌شود.</p>
<template #footer>
<a-button @click="variantModal = false">انصراف</a-button>
<a-button type="primary" @click="saveVariant">ثبت</a-button>
</template>
</a-modal>
</div>
</template>
@@ -475,10 +565,16 @@ interface VariantRow {
qty?: number;
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;
}
const colorOptions = ref<Array<{ id: number; title: string }>>([]);
const variants = ref<VariantRow[]>([]);
const variantModal = ref(false);
const editingIndex = ref<number | null>(null);
@@ -490,6 +586,11 @@ const variantForm = reactive<VariantRow>({
qty: 0,
is_active: true,
is_pre_order: false,
color: undefined,
color_str: undefined,
rial_pay: undefined,
details: '',
description: '',
image_id: null,
image_url: null,
});
@@ -503,7 +604,9 @@ const variantColumns = [
{ title: 'تصویر', dataIndex: 'image', width: 68, align: 'center' as const },
{ title: 'عنوان تنوع', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams', align: 'right' as const },
{ title: 'رنگ', dataIndex: 'color', align: 'center' as const },
{ title: 'اجرت', dataIndex: 'percentage_pay', align: 'right' as const },
{ title: 'اجرت ریالی', dataIndex: 'rial_pay', align: 'right' as const },
{ title: 'موجودی', dataIndex: 'qty', align: 'center' as const },
{ title: 'وضعیت', dataIndex: 'is_active', align: 'center' as const },
{ title: 'پیش‌سفارش', dataIndex: 'is_pre_order', align: 'center' as const },
@@ -525,6 +628,11 @@ function showModal(record?: VariantRow, index?: number) {
qty: 0,
is_active: true,
is_pre_order: false,
color: undefined,
color_str: undefined,
rial_pay: undefined,
details: '',
description: '',
image_id: null,
image_url: null,
});
@@ -537,6 +645,9 @@ function saveVariant() {
message.error('عنوان تنوع الزامی است');
return;
}
const matchedColor = colorOptions.value.find((c) => c.id === variantForm.color);
variantForm.color_str = matchedColor ? matchedColor.title : null;
const row: VariantRow = { ...variantForm };
if (editingIndex.value === null) {
variants.value.push(row);
@@ -581,6 +692,10 @@ async function handleSubmit() {
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
color: v.color ?? null,
rial_pay: v.rial_pay ?? null,
details: v.details || null,
description: v.description || null,
image_id: v.image_id ?? null,
})),
};
@@ -600,10 +715,11 @@ async function handleSubmit() {
onMounted(async () => {
try {
const [b, c, col] = await Promise.all([
const [b, c, col, colorsRes] = await Promise.all([
api.get('brand'),
api.post('category'),
users.isSuperAdmin ? api.get('admin/products/collections') : Promise.resolve({ data: [] }),
api.get('admin/products/colors').catch(() => ({ data: [] })),
]);
brands.value = b.data;
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
@@ -612,6 +728,7 @@ onMounted(async () => {
type: x.type,
}));
collections.value = col.data;
colorOptions.value = colorsRes.data ?? [];
} catch (e) {
console.log(e);
}
@@ -646,6 +763,11 @@ onMounted(async () => {
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
color: v.color != null ? Number(v.color) : undefined,
color_str: v.color_str ?? null,
rial_pay: v.rial_pay != null ? Number(v.rial_pay) : undefined,
details: v.details ?? '',
description: v.description ?? '',
image_id: v.image_id ?? null,
image_url: v.image?.url ?? null,
}));
@@ -1146,72 +1268,246 @@ onMounted(async () => {
color: #595959;
}
/* ── مدال تنوع و انتخابگر تصویر (PROTECTED §B) ── */
.form-grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
/* ── استایل مدرن، فشرده و دو ستونه مودال تنوع (بدون اسکرول) ── */
.vmodal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 10px;
border-bottom: 1px solid #edf2f7;
}
.variant-switches-row {
display: flex;
align-items: center;
gap: 20px;
margin: 14px 0 18px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid #f0f2f5;
.vmodal-header__icon {
width: 36px;
height: 36px;
border-radius: 8px;
}
.switch-box {
background: rgba(28, 45, 86, 0.08);
color: #1c2d56;
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.switch-box-label {
font-size: 13px;
color: #262626;
.vmodal-header__text h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1a202c;
line-height: 1.2;
}
.vmodal-header__text p {
margin: 2px 0 0;
font-size: 11.5px;
color: #718096;
}
.variant-picker {
.vmodal-form {
padding-top: 8px;
}
.vmodal-cols {
display: grid;
grid-template-columns: 1.05fr 0.95fr;
gap: 12px;
align-items: stretch;
}
.vmodal-col {
display: flex;
flex-direction: column;
gap: 10px;
}
.vmodal-card {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 10px 14px;
}
.vmodal-card__head {
font-size: 12.5px;
font-weight: 700;
color: #2d3748;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px dashed #e2e8f0;
position: relative;
padding-right: 10px;
}
.vmodal-card__head::before {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 12px;
background: #1c2d56;
border-radius: 2px;
}
.vmodal-grid {
display: grid;
gap: 8px;
}
.vmodal-grid--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vmodal-item {
margin-bottom: 6px;
}
.vmodal-item:last-child {
margin-bottom: 0;
}
.vmodal-item :deep(.ant-form-item-label) {
padding-bottom: 2px !important;
}
.vmodal-item :deep(.ant-form-item-label > label) {
font-size: 11.5px;
font-weight: 600;
color: #4a5568;
height: auto;
}
/* سوئیچ‌های فشرده */
.vmodal-switches {
display: flex;
gap: 10px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #e2e8f0;
}
.vmodal-switch-box {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
margin: 0;
}
.vmodal-switch-box:hover {
border-color: #cbd5e0;
}
.vmodal-switch-label {
font-size: 11.5px;
font-weight: 600;
color: #2d3748;
}
/* گزینه‌های رنگ طلا */
.color-opt {
display: flex;
align-items: center;
gap: 6px;
}
.color-opt__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.2);
display: inline-block;
}
.color-opt__dot--0 { background: #fdfdfd; } /* سفید */
.color-opt__dot--1 { background: #ffd700; } /* زرد */
.color-opt__dot--2 { background: #795548; } /* شکلاتی */
.color-opt__dot--3 { background: #b76e79; } /* رزگلد */
/* انتخابگر تصویر تنوع (فشرده) */
.vmodal-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.variant-picker__item {
width: 64px;
height: 64px;
max-height: 125px;
overflow-y: auto;
padding: 2px;
border: 2px solid #d9d9d9;
border-radius: 8px;
background: #fff;
}
.vmodal-picker__item {
position: relative;
width: 54px;
height: 54px;
padding: 2px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
cursor: pointer;
overflow: hidden;
transition: all 0.2s ease;
transition: all 0.15s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 10px;
color: #a0aec0;
}
.variant-picker__item:hover {
.vmodal-picker__item:hover {
border-color: #1c2d56;
}
.variant-picker__item img {
.vmodal-picker__item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
display: block;
}
.variant-picker__item.is-on {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
.vmodal-picker__item.is-active {
border-color: #1c2d56;
box-shadow: 0 0 0 2px rgba(28, 45, 86, 0.2);
}
.variant-picker__none {
.vmodal-picker__check {
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
background: #1c2d56;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 8px;
}
.vmodal-picker-empty {
font-size: 11px;
color: #8c8c8c;
line-height: 1.3;
padding: 8px;
text-align: center;
background: #fff;
border: 1px dashed #e2e8f0;
border-radius: 6px;
}
/* فوتر اختصاصی مودال */
.vmodal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 10px;
margin-top: 8px;
border-top: 1px solid #edf2f7;
}
.vmodal-footer__cancel {
border-radius: 6px;
min-width: 80px;
}
.vmodal-footer__submit {
border-radius: 6px;
background: #1c2d56;
border-color: #1c2d56;
min-width: 110px;
font-weight: 600;
}
.vmodal-footer__submit:hover {
background: #2b427a !important;
border-color: #2b427a !important;
}
/* ── ریسپانسیو ── */