Files
nademitex_admin/src/views/products/ProductsView.vue
T
2026-10-29 15:24:44 +03:30

877 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>