changes 1

This commit is contained in:
2026-10-29 15:24:44 +03:30
parent 6a52b845fc
commit 400fecf271
48 changed files with 869 additions and 360 deletions
+142 -149
View File
@@ -14,7 +14,6 @@ import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import { formatCurrency, formatNumber } from '@/utils/format'
import type { Company, Product } from '@/types'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { toCssBackgroundUrl } from '@/utils/catalogImage'
import {
PAYMENT_RECIPIENT_COMPANY_SLUG,
@@ -64,18 +63,15 @@ const companyFormOptions = computed(() => {
const emptyForm = () => ({
companyId: '',
name: '',
color: '',
productCode: '',
minMeterage: 100,
orderStep: 50,
price: null as number | null,
internalStock: 0,
widthCm: null as number | null,
weightGrams: null as number | null,
inStock: true,
isActive: true,
usageCategory: 'other',
season: 'all_season',
composition: '',
weaveType: '',
subtitle: '',
specs: '',
rollMeters: 50,
isNew: false,
@@ -91,7 +87,7 @@ const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
const isFormValid = computed(() => form.name.trim() && form.minMeterage > 0)
const isFormValid = computed(() => form.name.trim().length > 0 && form.rollMeters > 0)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
)
@@ -167,18 +163,15 @@ async function openEditModal(product: Product) {
}
form.companyId = productCompanyIdForForm(product.companyId, companies.value)
form.name = product.name
form.color = product.color
form.productCode = product.productCode
form.minMeterage = product.minMeterage
form.orderStep = product.orderStep
form.price = product.price
form.internalStock = product.internalStock
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.subtitle = (product as { subtitle?: string }).subtitle ?? ''
form.specs = (product as { specs?: string }).specs ?? ''
form.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
form.isNew = (product as { isNew?: boolean }).isNew ?? false
@@ -246,10 +239,7 @@ function buildProductMedia() {
form.colorRows.forEach((row, index) => {
const imageUrl = row.imageUrl.trim() || undefined
const name =
row.name.trim() ||
(index === 0 ? form.color.trim() : '') ||
(imageUrl ? `رنگ ${index + 1}` : '')
const name = row.name.trim() || (imageUrl ? `رنگ ${index + 1}` : '')
if (!name && !imageUrl) return
if (!name) return
colorEntries.push({
@@ -270,7 +260,7 @@ function closeModal() {
async function submitProduct() {
if (!isFormValid.value) {
formError.value = 'نام محصول و حداقل متراژ الزامی هستند.'
formError.value = 'نام محصول و متراژ هر طاقه الزامی هستند.'
return
}
@@ -284,32 +274,39 @@ async function submitProduct() {
}
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,
subtitle: form.subtitle.trim() || null,
specs: form.specs.trim() || undefined,
rollMeters: form.rollMeters,
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: form.color.trim() || colorEntries[0]?.name || '—',
color: colorLabel,
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
...meteragePayload,
...stockPayload,
isActive: form.isActive,
...catalogPayload,
})
@@ -321,13 +318,11 @@ async function submitProduct() {
const created = await productsApi.create({
companyId: companyIdPayload,
name: form.name.trim(),
color: form.color.trim() || colorEntries[0]?.name || '—',
color: colorLabel,
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
...meteragePayload,
...stockPayload,
isActive: form.isActive,
...catalogPayload,
})
@@ -524,120 +519,9 @@ function clearFilters() {
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>شرکت مرتبط با محصول</label>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت مرتبط با محصول"
placeholder="نادمی تکس (پیش‌فرض)"
:options="companyFormOptions"
/>
</div>
<div class="admin-form__row">
<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-color">رنگ / نوع</label>
<input id="product-color" v-model="form.color" type="text" class="admin-input" />
</div>
</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__row">
<div class="admin-form__field">
<label for="product-min">حداقل متراژ</label>
<input id="product-min" v-model.number="form.minMeterage" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-step">ضریب سفارش</label>
<input id="product-step" v-model.number="form.orderStep" type="number" min="1" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-price">قیمت هر متر (تومان)</label>
<AdminCurrencyInput id="product-price" v-model="form.price" placeholder="مثلاً ۲۵۰٬۰۰۰" />
</div>
<div class="admin-form__field">
<label for="product-stock">موجودی داخلی (متر)</label>
<input id="product-stock" v-model.number="form.internalStock" type="number" min="0" class="admin-input" />
</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__row">
<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>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-subtitle">زیرعنوان</label>
<input id="product-subtitle" v-model="form.subtitle" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-roll">متراژ طاقه</label>
<input id="product-roll" v-model.number="form.rollMeters" type="number" min="1" class="admin-input" />
</div>
</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>
<label>عکس اسلایدی محصول</label>
<p class="color-variants__hint">
فقط برای گالری اصلی در اپ کاربر (بدون وابستگی به رنگ). با «ثبت تغییرات» ثبت می‌شود.
تصاویر گالری اصلی در اپ کاربر. با «ثبت محصول» یا «ثبت تغییرات» ذخیره می‌شود.
</p>
<div class="general-images">
<div
@@ -645,7 +529,7 @@ function clearFilters() {
:key="`general-${gIdx}`"
class="product-media-row"
role="group"
:aria-label="`تصویر عمومی ${gIdx + 1}`"
:aria-label="`تصویر اسلاید ${gIdx + 1}`"
>
<AdminImagePicker
compact
@@ -664,15 +548,75 @@ function clearFilters() {
</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>رنگ‌بندی‌ها</label>
<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
@@ -711,12 +655,61 @@ function clearFilters() {
</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="وضعیت" />
<BaseSwitch v-model="form.isActive" label="فعال در فروشگاه" />
</div>
</form>