877 lines
28 KiB
Vue
877 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
|
||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
|
||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||
import AdminSelect from '@/components/ui/AdminSelect.vue'
|
||
import { companiesApi, productsApi, settingsApi } from '@/services/api'
|
||
import { ApiError } from '@/services/http'
|
||
import { useToast } from '@/composables/useToast'
|
||
import { confirm } from '@/composables/useConfirm'
|
||
import { formatCurrency, formatNumber } from '@/utils/format'
|
||
import type { Company, Product } from '@/types'
|
||
import { toCssBackgroundUrl } from '@/utils/catalogImage'
|
||
import {
|
||
PAYMENT_RECIPIENT_COMPANY_SLUG,
|
||
productCompanyIdForForm,
|
||
resolveProductCompanyPayload,
|
||
} from '@/utils/company'
|
||
|
||
const productList = ref<Product[]>([])
|
||
const companies = ref<Company[]>([])
|
||
const usageOptions = ref<Array<{ value: string; label: string }>>([
|
||
{ value: 'trousers', label: 'شلوار' },
|
||
{ value: 'suit', label: 'کتوشلوار' },
|
||
{ value: 'shirt', label: 'پیراهن' },
|
||
{ value: 'other', label: 'سایر' },
|
||
])
|
||
const seasonOptions = ref<Array<{ value: string; label: string }>>([
|
||
{ value: 'summer', label: 'تابستان' },
|
||
{ value: 'winter', label: 'زمستان' },
|
||
{ value: 'all_season', label: 'چهارفصل' },
|
||
])
|
||
const searchQuery = ref('')
|
||
const companyFilter = ref('all')
|
||
const loading = ref(true)
|
||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||
const editingId = ref<string | null>(null)
|
||
const formError = ref('')
|
||
const saving = ref(false)
|
||
const toast = useToast()
|
||
|
||
const companyFilterOptions = computed(() => [
|
||
{ value: 'all', label: 'همه شرکتها' },
|
||
...companies.value.map((company) => ({
|
||
value: company.id,
|
||
label: company.name,
|
||
})),
|
||
])
|
||
|
||
const companyFormOptions = computed(() => {
|
||
const opts = [{ value: '', label: 'نادمی تکس (پیشفرض)' }]
|
||
for (const company of companies.value) {
|
||
if (!company.isActive || company.slug === PAYMENT_RECIPIENT_COMPANY_SLUG) continue
|
||
opts.push({ value: company.id, label: company.name })
|
||
}
|
||
return opts
|
||
})
|
||
|
||
const emptyForm = () => ({
|
||
companyId: '',
|
||
name: '',
|
||
productCode: '',
|
||
widthCm: null as number | null,
|
||
weightGrams: null as number | null,
|
||
inStock: true,
|
||
isActive: true,
|
||
usageCategory: 'other',
|
||
season: 'all_season',
|
||
composition: '',
|
||
weaveType: '',
|
||
specs: '',
|
||
rollMeters: 50,
|
||
isNew: false,
|
||
generalImages: [] as string[],
|
||
colorRows: [{ id: crypto.randomUUID(), name: '', imageUrl: '' }] as Array<{
|
||
id: string
|
||
name: string
|
||
imageUrl: string
|
||
}>,
|
||
})
|
||
|
||
const form = reactive(emptyForm())
|
||
|
||
const isModalOpen = computed(() => modalMode.value !== null)
|
||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
|
||
const isFormValid = computed(() => form.name.trim().length > 0 && form.rollMeters > 0)
|
||
const hasActiveFilters = computed(
|
||
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
|
||
)
|
||
|
||
const filteredProducts = computed(() => {
|
||
let list = productList.value
|
||
if (companyFilter.value !== 'all') {
|
||
list = list.filter((p) => p.companyId === companyFilter.value)
|
||
}
|
||
const q = searchQuery.value.trim()
|
||
if (q) {
|
||
list = list.filter(
|
||
(p) =>
|
||
p.name.includes(q) ||
|
||
p.color.includes(q) ||
|
||
p.productCode.includes(q) ||
|
||
(p.catalogCode ?? p.productCode).includes(q),
|
||
)
|
||
}
|
||
return list
|
||
})
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const [productsData, companiesData, settings] = await Promise.all([
|
||
productsApi.list(),
|
||
companiesApi.list(),
|
||
settingsApi.get().catch(() => null),
|
||
])
|
||
productList.value = productsData
|
||
companies.value = companiesData
|
||
if (settings?.categoryDefsJson?.usage?.length) {
|
||
usageOptions.value = [
|
||
...settings.categoryDefsJson.usage.map((item) => ({
|
||
value: item.id,
|
||
label: item.title,
|
||
})),
|
||
{ value: 'other', label: 'سایر' },
|
||
]
|
||
}
|
||
if (settings?.categoryDefsJson?.seasons?.length) {
|
||
seasonOptions.value = settings.categoryDefsJson.seasons.map((item) => ({
|
||
value: item.id,
|
||
label: item.title,
|
||
}))
|
||
}
|
||
} catch (err) {
|
||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadData()
|
||
})
|
||
|
||
function openAddModal() {
|
||
modalMode.value = 'add'
|
||
editingId.value = null
|
||
Object.assign(form, emptyForm())
|
||
formError.value = ''
|
||
}
|
||
|
||
async function openEditModal(product: Product) {
|
||
modalMode.value = 'edit'
|
||
editingId.value = product.id
|
||
try {
|
||
product = await productsApi.getById(product.id)
|
||
} catch {
|
||
/* از ردیف جدول */
|
||
}
|
||
form.companyId = productCompanyIdForForm(product.companyId, companies.value)
|
||
form.name = product.name
|
||
form.productCode = product.productCode
|
||
form.widthCm = product.widthCm ?? null
|
||
form.weightGrams = product.weightGrams ?? null
|
||
form.inStock = product.internalStock == null || product.internalStock > 0
|
||
form.isActive = product.isActive
|
||
form.usageCategory = (product as { usageCategory?: string }).usageCategory ?? 'other'
|
||
form.season = (product as { season?: string }).season ?? 'all_season'
|
||
form.composition = (product as { composition?: string }).composition ?? ''
|
||
form.weaveType = (product as { weaveType?: string }).weaveType ?? ''
|
||
form.specs = (product as { specs?: string }).specs ?? ''
|
||
form.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
|
||
form.isNew = (product as { isNew?: boolean }).isNew ?? false
|
||
form.generalImages = [...(product.imageUrls ?? [])]
|
||
const colors = product.colors?.length
|
||
? product.colors
|
||
: product.color?.trim()
|
||
? [product.color.trim()]
|
||
: []
|
||
if (colors.length) {
|
||
form.colorRows = colors.map((name, index) => {
|
||
const fromList = product.colorVariantImages?.[index]?.[0]
|
||
const legacy = product.colorImages?.[index]
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
name,
|
||
imageUrl: fromList || legacy || '',
|
||
}
|
||
})
|
||
} else {
|
||
form.colorRows = [{ id: crypto.randomUUID(), name: '', imageUrl: '' }]
|
||
}
|
||
formError.value = ''
|
||
}
|
||
|
||
function addColorRow() {
|
||
form.colorRows.push({ id: crypto.randomUUID(), name: '', imageUrl: '' })
|
||
}
|
||
|
||
function removeColorRow(index: number) {
|
||
if (form.colorRows.length <= 1) {
|
||
form.colorRows[0] = {
|
||
id: form.colorRows[0]?.id ?? crypto.randomUUID(),
|
||
name: '',
|
||
imageUrl: '',
|
||
}
|
||
return
|
||
}
|
||
form.colorRows.splice(index, 1)
|
||
}
|
||
|
||
function addGeneralImageSlot() {
|
||
form.generalImages.push('')
|
||
}
|
||
|
||
function removeGeneralImage(index: number) {
|
||
form.generalImages.splice(index, 1)
|
||
}
|
||
|
||
function uniqueUrls(urls: string[]) {
|
||
const out: string[] = []
|
||
const seen = new Set<string>()
|
||
for (const raw of urls) {
|
||
const url = raw.trim()
|
||
if (!url || seen.has(url)) continue
|
||
seen.add(url)
|
||
out.push(url)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function buildProductMedia() {
|
||
const imageUrls = uniqueUrls(form.generalImages)
|
||
const colorEntries: Array<{ name: string; imageUrls: string[]; imageUrl?: string }> = []
|
||
|
||
form.colorRows.forEach((row, index) => {
|
||
const imageUrl = row.imageUrl.trim() || undefined
|
||
const name = row.name.trim() || (imageUrl ? `رنگ ${index + 1}` : '')
|
||
if (!name && !imageUrl) return
|
||
if (!name) return
|
||
colorEntries.push({
|
||
name,
|
||
imageUrls: imageUrl ? [imageUrl] : [],
|
||
imageUrl,
|
||
})
|
||
})
|
||
|
||
return { colorEntries, imageUrls }
|
||
}
|
||
|
||
function closeModal() {
|
||
modalMode.value = null
|
||
editingId.value = null
|
||
formError.value = ''
|
||
}
|
||
|
||
async function submitProduct() {
|
||
if (!isFormValid.value) {
|
||
formError.value = 'نام محصول و متراژ هر طاقه الزامی هستند.'
|
||
return
|
||
}
|
||
|
||
const companyIdPayload = resolveProductCompanyPayload(form.companyId)
|
||
if (form.companyId.trim()) {
|
||
const company = companies.value.find((c) => c.id === form.companyId)
|
||
if (!company?.isActive) {
|
||
formError.value = 'شرکت معتبر انتخاب کنید.'
|
||
return
|
||
}
|
||
}
|
||
|
||
const { colorEntries, imageUrls } = buildProductMedia()
|
||
const rollMeters = form.rollMeters
|
||
const catalogPayload = {
|
||
usageCategory: form.usageCategory,
|
||
season: form.season,
|
||
composition: form.composition.trim() || null,
|
||
weaveType: form.weaveType.trim() || null,
|
||
specs: form.specs.trim() || undefined,
|
||
rollMeters,
|
||
widthCm: form.widthCm,
|
||
weightGrams: form.weightGrams,
|
||
isNew: form.isNew,
|
||
colorsJson: colorEntries,
|
||
imageUrlsJson: imageUrls,
|
||
}
|
||
const productCode = form.productCode.trim() || `PRD-${Date.now()}`
|
||
const colorLabel = colorEntries[0]?.name || '—'
|
||
const meteragePayload = {
|
||
minMeterage: rollMeters,
|
||
orderStep: rollMeters,
|
||
orderMultiplier: rollMeters,
|
||
}
|
||
const stockPayload = { internalStock: form.inStock ? null : 0 }
|
||
saving.value = true
|
||
try {
|
||
if (modalMode.value === 'edit' && editingId.value) {
|
||
await productsApi.update(editingId.value, {
|
||
companyId: companyIdPayload,
|
||
name: form.name.trim(),
|
||
color: colorLabel,
|
||
code: productCode,
|
||
productCode,
|
||
...meteragePayload,
|
||
...stockPayload,
|
||
isActive: form.isActive,
|
||
...catalogPayload,
|
||
})
|
||
const fresh = await productsApi.getById(editingId.value)
|
||
const index = productList.value.findIndex((p) => p.id === editingId.value)
|
||
if (index !== -1) productList.value[index] = fresh
|
||
toast.success('اطلاعات محصول بهروزرسانی شد.')
|
||
} else {
|
||
const created = await productsApi.create({
|
||
companyId: companyIdPayload,
|
||
name: form.name.trim(),
|
||
color: colorLabel,
|
||
code: productCode,
|
||
productCode,
|
||
...meteragePayload,
|
||
...stockPayload,
|
||
isActive: form.isActive,
|
||
...catalogPayload,
|
||
})
|
||
productList.value.push(created)
|
||
toast.success('محصول جدید ثبت شد.')
|
||
}
|
||
closeModal()
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.status === 0) {
|
||
formError.value = err.message
|
||
}
|
||
toast.error(err instanceof ApiError ? err.message : 'ذخیره محصول انجام نشد.')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function persistActive(id: string, isActive: boolean) {
|
||
await productsApi.setActive(id, isActive)
|
||
}
|
||
|
||
async function removeProduct(product: Product) {
|
||
const ok = await confirm({
|
||
title: 'حذف محصول',
|
||
message: `آیا از حذف دائمی «${product.name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.`,
|
||
confirmLabel: 'حذف',
|
||
cancelLabel: 'انصراف',
|
||
tone: 'danger',
|
||
})
|
||
if (!ok) return
|
||
try {
|
||
await productsApi.remove(product.id)
|
||
productList.value = productList.value.filter((item) => item.id !== product.id)
|
||
toast.success('محصول حذف شد.')
|
||
} catch (err) {
|
||
toast.error(err instanceof ApiError ? err.message : 'حذف محصول انجام نشد.')
|
||
}
|
||
}
|
||
|
||
function clearFilters() {
|
||
searchQuery.value = ''
|
||
companyFilter.value = 'all'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="products-page">
|
||
<div class="admin-page-header">
|
||
<div>
|
||
<h1 class="admin-page-header__title">محصولات</h1>
|
||
</div>
|
||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||
افزودن محصول
|
||
</button>
|
||
</div>
|
||
|
||
<div class="products-page__filters admin-card">
|
||
<div class="products-page__search">
|
||
<AdminIcon name="search" :size="18" />
|
||
<input
|
||
v-model="searchQuery"
|
||
type="search"
|
||
class="products-page__search-input"
|
||
placeholder="جستجو در نام، رنگ، کد..."
|
||
/>
|
||
</div>
|
||
<AdminSelect
|
||
v-model="companyFilter"
|
||
class="products-page__catalog-filter"
|
||
aria-label="فیلتر شرکت"
|
||
:options="companyFilterOptions"
|
||
/>
|
||
</div>
|
||
|
||
<AdminPageSkeleton v-if="loading" variant="table" />
|
||
<BaseEmptyState
|
||
v-else-if="!filteredProducts.length"
|
||
class="admin-card"
|
||
icon="products"
|
||
:title="hasActiveFilters ? 'نتیجهای یافت نشد' : 'محصولی ثبت نشده'"
|
||
:description="
|
||
hasActiveFilters
|
||
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
|
||
: 'برای شروع، اولین محصول را ثبت کنید.'
|
||
"
|
||
>
|
||
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
|
||
پاک کردن فیلترها
|
||
</button>
|
||
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||
افزودن محصول
|
||
</button>
|
||
</BaseEmptyState>
|
||
|
||
<div v-else class="products-page__table admin-card desktop-only">
|
||
<div class="admin-table-wrap">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>تصویر</th>
|
||
<th>کد محصول</th>
|
||
<th>نام</th>
|
||
<th>رنگ</th>
|
||
<th>شرکت</th>
|
||
<th class="numeric">حداقل متراژ</th>
|
||
<th class="numeric">ضریب</th>
|
||
<th class="numeric">قیمت هر متر</th>
|
||
<th class="numeric">موجودی</th>
|
||
<th class="status">وضعیت</th>
|
||
<th class="actions">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="product in filteredProducts" :key="product.id">
|
||
<td>
|
||
<span
|
||
class="product-thumb"
|
||
:class="{ 'product-thumb--empty': !product.imageUrls?.[0] }"
|
||
:style="product.imageUrls?.[0] ? { backgroundImage: toCssBackgroundUrl(product.imageUrls[0]) } : undefined"
|
||
/>
|
||
</td>
|
||
<td>{{ product.productCode }}</td>
|
||
<td>{{ product.name }}</td>
|
||
<td>{{ product.color }}</td>
|
||
<td>{{ product.companyName }}</td>
|
||
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
|
||
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
|
||
<td class="numeric">
|
||
{{ product.price ? `${formatCurrency(product.price)} / متر` : '—' }}
|
||
</td>
|
||
<td class="numeric">{{ formatNumber(product.internalStock) }} متر</td>
|
||
<td class="status">
|
||
<ActiveSwitchCell
|
||
:id="product.id"
|
||
v-model="product.isActive"
|
||
:entity-name="product.name"
|
||
entity-type="محصول"
|
||
:persist="persistActive"
|
||
/>
|
||
</td>
|
||
<td class="actions">
|
||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
|
||
ویرایش
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||
@click="removeProduct(product)"
|
||
>
|
||
حذف
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!loading && filteredProducts.length" class="compact-only admin-card-list">
|
||
<article v-for="product in filteredProducts" :key="product.id" class="admin-card-item">
|
||
<div class="admin-card-item__header">
|
||
<p class="admin-card-item__title">{{ product.name }}</p>
|
||
<div class="admin-card-item__header-actions">
|
||
<ActiveSwitchCell
|
||
:id="product.id"
|
||
v-model="product.isActive"
|
||
:entity-name="product.name"
|
||
entity-type="محصول"
|
||
:persist="persistActive"
|
||
/>
|
||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
|
||
ویرایش
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||
@click="removeProduct(product)"
|
||
>
|
||
حذف
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="admin-card-item__meta">
|
||
<span>{{ product.productCode }}</span>
|
||
<span>{{ product.color }}</span>
|
||
<span dir="ltr">کد {{ product.productCode }}</span>
|
||
<span>{{ product.price ? `${formatCurrency(product.price)} / متر` : 'بدون قیمت' }}</span>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||
<form class="admin-form" @submit.prevent="submitProduct">
|
||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||
|
||
<div class="admin-form__field">
|
||
<label>عکس اسلایدی محصول</label>
|
||
<p class="color-variants__hint">
|
||
تصاویر گالری اصلی در اپ کاربر. با «ثبت محصول» یا «ثبت تغییرات» ذخیره میشود.
|
||
</p>
|
||
<div class="general-images">
|
||
<div
|
||
v-for="(url, gIdx) in form.generalImages"
|
||
:key="`general-${gIdx}`"
|
||
class="product-media-row"
|
||
role="group"
|
||
:aria-label="`تصویر اسلاید ${gIdx + 1}`"
|
||
>
|
||
<AdminImagePicker
|
||
compact
|
||
:show-remove="false"
|
||
:model-value="url || null"
|
||
@update:model-value="form.generalImages[gIdx] = $event ?? ''"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="product-media-row__delete"
|
||
aria-label="حذف این ردیف تصویر"
|
||
title="حذف این ردیف"
|
||
@click="removeGeneralImage(gIdx)"
|
||
>
|
||
<AdminIcon name="trash" :size="17" />
|
||
</button>
|
||
</div>
|
||
<button type="button" class="admin-btn admin-btn--outline product-media-add" @click="addGeneralImageSlot">
|
||
+ افزودن تصویر اسلاید
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
|
||
<input id="product-name" v-model="form.name" type="text" class="admin-input" />
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-code">کد محصول</label>
|
||
<input id="product-code" v-model="form.productCode" type="text" class="admin-input" dir="ltr" />
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-width">عرض (سانتیمتر)</label>
|
||
<input
|
||
id="product-width"
|
||
v-model.number="form.widthCm"
|
||
type="number"
|
||
min="0"
|
||
step="0.1"
|
||
class="admin-input"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-weight">وزن (گرم)</label>
|
||
<input
|
||
id="product-weight"
|
||
v-model.number="form.weightGrams"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="admin-input"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-composition">جنس</label>
|
||
<input id="product-composition" v-model="form.composition" type="text" class="admin-input" />
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-weave">نوع بافت</label>
|
||
<input id="product-weave" v-model="form.weaveType" type="text" class="admin-input" />
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-specs">توضیح اضافه و تکمیلی برای مشخصه فنی</label>
|
||
<p class="admin-form__hint">
|
||
در بخش «مشاهده همه مشخصات» صفحه محصول در اپ نمایش داده میشود.
|
||
</p>
|
||
<textarea
|
||
id="product-specs"
|
||
v-model="form.specs"
|
||
class="admin-form__textarea"
|
||
rows="5"
|
||
placeholder="نکات تکمیلی مشخصات فنی — هر خط یک بند"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label>رنگبندی</label>
|
||
<p class="color-variants__hint">
|
||
برای هر رنگ یک نام و یک تصویر بگذارید. تصویر رنگ در بخش رنگبندی اپ نمایش داده میشود.
|
||
</p>
|
||
<div class="color-variants">
|
||
<div
|
||
v-for="(row, index) in form.colorRows"
|
||
:key="row.id"
|
||
class="product-media-row product-media-row--color"
|
||
role="group"
|
||
:aria-label="`رنگ ${index + 1}`"
|
||
>
|
||
<AdminImagePicker
|
||
compact
|
||
:show-remove="false"
|
||
:model-value="row.imageUrl || null"
|
||
@update:model-value="row.imageUrl = $event ?? ''"
|
||
/>
|
||
<input
|
||
v-model="row.name"
|
||
type="text"
|
||
class="admin-input product-media-row__input"
|
||
:placeholder="`نام رنگ ${index + 1}`"
|
||
:aria-label="`نام رنگ ${index + 1}`"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="product-media-row__delete"
|
||
aria-label="حذف این رنگ"
|
||
title="حذف این رنگ"
|
||
@click="removeColorRow(index)"
|
||
>
|
||
<AdminIcon name="trash" :size="17" />
|
||
</button>
|
||
</div>
|
||
<button type="button" class="admin-btn admin-btn--outline product-media-add" @click="addColorRow">
|
||
+ افزودن رنگ
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-form__row">
|
||
<div class="admin-form__field">
|
||
<label for="product-usage">دستهبندی</label>
|
||
<AdminSelect
|
||
id="product-usage"
|
||
v-model="form.usageCategory"
|
||
block
|
||
aria-label="دستهبندی محصول"
|
||
placeholder="انتخاب دستهبندی"
|
||
:options="usageOptions"
|
||
/>
|
||
</div>
|
||
<div class="admin-form__field">
|
||
<label for="product-season">فصل</label>
|
||
<AdminSelect
|
||
id="product-season"
|
||
v-model="form.season"
|
||
block
|
||
aria-label="فصل محصول"
|
||
placeholder="انتخاب فصل"
|
||
:options="seasonOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="product-roll">متراژ هر طاقه <span class="admin-form__required">*</span></label>
|
||
<p class="admin-form__hint">
|
||
حداقل متراژ و ضریب سفارش بهصورت خودکار برابر با همین مقدار تنظیم میشود.
|
||
</p>
|
||
<input id="product-roll" v-model.number="form.rollMeters" type="number" min="1" class="admin-input" />
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<BaseSwitch v-model="form.inStock" label="موجود در انبار" />
|
||
<p class="admin-form__hint">در صورت اتمام موجودی، خاموش کنید؛ نیازی به ثبت متراژ دقیق نیست.</p>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label>شرکت مرتبط با محصول</label>
|
||
<AdminSelect
|
||
v-model="form.companyId"
|
||
block
|
||
aria-label="انتخاب شرکت مرتبط با محصول"
|
||
placeholder="نادمی تکس (پیشفرض)"
|
||
:options="companyFormOptions"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<BaseSwitch v-model="form.isNew" label="محصول جدید" />
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<BaseSwitch v-model="form.isActive" label="فعال در فروشگاه" />
|
||
</div>
|
||
</form>
|
||
|
||
<template #footer>
|
||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitProduct">
|
||
{{ modalMode === 'edit' ? 'ثبت تغییرات' : 'ثبت محصول' }}
|
||
</button>
|
||
</template>
|
||
</BaseModal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.products-page__filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
margin-bottom: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.products-page__search {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.products-page__search-input {
|
||
flex: 1;
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 0.875rem;
|
||
outline: none;
|
||
}
|
||
|
||
.products-page__catalog-filter {
|
||
width: min(260px, 100%);
|
||
}
|
||
|
||
.admin-form__hint {
|
||
margin: 6px 0 0;
|
||
font-size: 0.75rem;
|
||
line-height: 1.5;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.products-page__table {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.product-thumb {
|
||
display: block;
|
||
width: 44px;
|
||
height: 36px;
|
||
border-radius: 8px;
|
||
background: #e8eceb center / cover no-repeat;
|
||
}
|
||
|
||
.product-thumb--empty {
|
||
background: #eef1f0;
|
||
}
|
||
|
||
.color-variants__hint {
|
||
margin: 0 0 10px;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.75rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.general-images,
|
||
.color-variants {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.product-media-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
align-items: center;
|
||
gap: 12px 14px;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 14px;
|
||
background: var(--color-elevated, #fff);
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.product-media-row--color {
|
||
grid-template-columns: auto 1fr auto;
|
||
align-items: center;
|
||
}
|
||
|
||
.product-media-row :deep(.image-picker) {
|
||
min-width: 0;
|
||
}
|
||
|
||
.product-media-row__input {
|
||
min-width: 0;
|
||
height: 44px;
|
||
}
|
||
|
||
.product-media-row__delete {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
padding: 0;
|
||
border: 1px solid transparent;
|
||
border-radius: 12px;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
transition:
|
||
color 0.15s ease,
|
||
background 0.15s ease,
|
||
border-color 0.15s ease;
|
||
}
|
||
|
||
.product-media-row__delete:hover {
|
||
color: #c53030;
|
||
background: rgba(197, 48, 48, 0.08);
|
||
border-color: rgba(197, 48, 48, 0.18);
|
||
}
|
||
|
||
.product-media-row__delete:focus-visible {
|
||
outline: 2px solid var(--color-primary, #2d6a4f);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.product-media-add {
|
||
align-self: stretch;
|
||
justify-content: center;
|
||
}
|
||
|
||
@media (max-width: 520px) {
|
||
.product-media-row--color {
|
||
grid-template-columns: 1fr auto;
|
||
grid-template-rows: auto auto;
|
||
}
|
||
|
||
.product-media-row--color :deep(.image-picker) {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.product-media-row--color .product-media-row__delete {
|
||
grid-row: 2;
|
||
grid-column: 2;
|
||
}
|
||
|
||
.product-media-row--color .product-media-row__input {
|
||
grid-row: 2;
|
||
grid-column: 1;
|
||
}
|
||
}
|
||
|
||
</style>
|