Initial admin edits

This commit is contained in:
2026-10-01 20:50:23 +03:30
parent 990883ae11
commit 16806a4d3c
124 changed files with 1964 additions and 740 deletions
+218 -115
View File
@@ -8,17 +8,22 @@ 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 { brandsApi, companiesApi, productsApi, settingsApi, type Brand } from '@/services/api'
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 AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { toCssBackgroundUrl } from '@/utils/catalogImage'
import {
PAYMENT_RECIPIENT_COMPANY_SLUG,
productCompanyIdForForm,
resolveProductCompanyPayload,
} from '@/utils/company'
const productList = ref<Product[]>([])
const companies = ref<Company[]>([])
const brands = ref<Brand[]>([])
const usageOptions = ref<Array<{ value: string; label: string }>>([
{ value: 'trousers', label: 'شلوار' },
{ value: 'suit', label: 'کت‌وشلوار' },
@@ -47,25 +52,17 @@ const companyFilterOptions = computed(() => [
})),
])
const companyFormOptions = computed(() =>
companies.value
.filter((c) => c.isActive)
.map((company) => ({
value: company.id,
label: company.name,
})),
)
const brandFormOptions = computed(() => [
{ value: '', label: 'بدون برند' },
...brands.value
.filter((b) => b.isActive)
.map((b) => ({ value: b.id, label: b.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: companies.value[0]?.id ?? '',
brandId: '',
companyId: '',
name: '',
color: '',
productCode: '',
@@ -79,6 +76,7 @@ const emptyForm = () => ({
composition: '',
weaveType: '',
subtitle: '',
specs: '',
rollMeters: 50,
isNew: false,
generalImages: [] as string[],
@@ -93,7 +91,7 @@ const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
const isFormValid = computed(() => form.name.trim() && form.companyId && form.minMeterage > 0)
const isFormValid = computed(() => form.name.trim() && form.minMeterage > 0)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
)
@@ -119,15 +117,13 @@ const filteredProducts = computed(() => {
async function loadData() {
loading.value = true
try {
const [productsData, companiesData, brandsData, settings] = await Promise.all([
const [productsData, companiesData, settings] = await Promise.all([
productsApi.list(),
companiesApi.list(),
brandsApi.list(),
settingsApi.get().catch(() => null),
])
productList.value = productsData
companies.value = companiesData
brands.value = brandsData
if (settings?.categoryDefsJson?.usage?.length) {
usageOptions.value = [
...settings.categoryDefsJson.usage.map((item) => ({
@@ -169,8 +165,7 @@ async function openEditModal(product: Product) {
} catch {
/* از ردیف جدول */
}
form.companyId = product.companyId ?? companies.value[0]?.id ?? ''
form.brandId = product.brandId ?? ''
form.companyId = productCompanyIdForForm(product.companyId, companies.value)
form.name = product.name
form.color = product.color
form.productCode = product.productCode
@@ -184,10 +179,15 @@ async function openEditModal(product: Product) {
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
form.generalImages = [...(product.imageUrls ?? [])]
const colors = product.colors ?? []
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]
@@ -199,7 +199,7 @@ async function openEditModal(product: Product) {
}
})
} else {
form.colorRows = [{ id: crypto.randomUUID(), name: product.color || '', imageUrl: '' }]
form.colorRows = [{ id: crypto.randomUUID(), name: '', imageUrl: '' }]
}
formError.value = ''
}
@@ -270,14 +270,17 @@ function closeModal() {
async function submitProduct() {
if (!isFormValid.value) {
formError.value = 'نام محصول، شرکت و حداقل متراژ الزامی هستند.'
formError.value = 'نام محصول و حداقل متراژ الزامی هستند.'
return
}
const company = companies.value.find((c) => c.id === form.companyId)
if (!company) {
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()
@@ -287,6 +290,7 @@ async function submitProduct() {
composition: form.composition.trim() || null,
weaveType: form.weaveType.trim() || null,
subtitle: form.subtitle.trim() || null,
specs: form.specs.trim() || undefined,
rollMeters: form.rollMeters,
isNew: form.isNew,
colorsJson: colorEntries,
@@ -297,6 +301,7 @@ async function submitProduct() {
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 || '—',
code: productCode,
@@ -314,7 +319,7 @@ async function submitProduct() {
toast.success('اطلاعات محصول به‌روزرسانی شد.')
} else {
const created = await productsApi.create({
companyId: company.id,
companyId: companyIdPayload,
name: form.name.trim(),
color: form.color.trim() || colorEntries[0]?.name || '—',
code: productCode,
@@ -324,7 +329,6 @@ async function submitProduct() {
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
brandId: form.brandId || null,
...catalogPayload,
})
productList.value.push(created)
@@ -345,6 +349,24 @@ 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'
@@ -409,8 +431,6 @@ function clearFilters() {
<th>کد محصول</th>
<th>نام</th>
<th>رنگ</th>
<th>برند</th>
<th>کد محصول</th>
<th>شرکت</th>
<th class="numeric">حداقل متراژ</th>
<th class="numeric">ضریب</th>
@@ -432,8 +452,6 @@ function clearFilters() {
<td>{{ product.productCode }}</td>
<td>{{ product.name }}</td>
<td>{{ product.color }}</td>
<td>{{ product.brandName || '—' }}</td>
<td dir="ltr">{{ product.productCode }}</td>
<td>{{ product.companyName }}</td>
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
@@ -454,6 +472,13 @@ function clearFilters() {
<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>
@@ -476,6 +501,13 @@ function clearFilters() {
<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">
@@ -492,27 +524,16 @@ function clearFilters() {
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>شرکت <span class="admin-form__required">*</span></label>
<label>شرکت مرتبط با محصول</label>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت"
placeholder="انتخاب شرکت"
aria-label="انتخاب شرکت مرتبط با محصول"
placeholder="نادمی تکس (پیش‌فرض)"
:options="companyFormOptions"
/>
</div>
<div class="admin-form__field">
<label>برند</label>
<AdminSelect
v-model="form.brandId"
block
aria-label="برند محصول"
placeholder="انتخاب برند"
:options="brandFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
@@ -544,9 +565,7 @@ function clearFilters() {
<div class="admin-form__field">
<label for="product-price">قیمت هر متر (تومان)</label>
<AdminCurrencyInput id="product-price" v-model="form.price" placeholder="مثلاً ۲۵۰٬۰۰۰" />
<p class="admin-form__hint">
مبلغ به‌ازای هر متر پارچه؛ در سفارش با متراژ تأیید‌شده ضرب می‌شود.
</p>
</div>
<div class="admin-form__field">
<label for="product-stock">موجودی داخلی (متر)</label>
@@ -557,19 +576,25 @@ function clearFilters() {
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-usage">کاربرد</label>
<select id="product-usage" v-model="form.usageCategory" class="admin-input">
<option v-for="opt in usageOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<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>
<select id="product-season" v-model="form.season" class="admin-input">
<option v-for="opt in seasonOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<AdminSelect
id="product-season"
v-model="form.season"
block
aria-label="فصل محصول"
placeholder="انتخاب فصل"
:options="seasonOptions"
/>
</div>
</div>
@@ -595,30 +620,50 @@ function clearFilters() {
</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>
<p class="color-variants__hint">
فقط برای گالری اصلی در اپ کاربر (بدون وابستگی به رنگ). با «ذخیره تغییرات» ثبت می‌شود.
فقط برای گالری اصلی در اپ کاربر (بدون وابستگی به رنگ). با «ثبت تغییرات» ثبت می‌شود.
</p>
<div class="general-images">
<div
v-for="(url, gIdx) in form.generalImages"
:key="`general-${gIdx}`"
class="general-images__row"
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="admin-btn admin-btn--ghost admin-btn--xs"
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" @click="addGeneralImageSlot">
<button type="button" class="admin-btn admin-btn--outline product-media-add" @click="addGeneralImageSlot">
+ افزودن تصویر عمومی
</button>
</div>
@@ -630,25 +675,37 @@ function clearFilters() {
برای هر رنگ یک نام و یک تصویر بگذارید. تصویر رنگ در بخش رنگ‌بندی اپ نمایش داده می‌شود، نه در گالری عمومی.
</p>
<div class="color-variants">
<div v-for="(row, index) in form.colorRows" :key="row.id" class="color-variant">
<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
:model-value="row.imageUrl || null"
compact
:show-remove="false"
:model-value="row.imageUrl || null"
@update:model-value="row.imageUrl = $event ?? ''"
/>
<div class="color-variant__fields">
<input
v-model="row.name"
type="text"
class="admin-input"
:placeholder="`نام رنگ ${index + 1}`"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="removeColorRow(index)">
حذف این رنگ
</button>
</div>
<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" @click="addColorRow">
<button type="button" class="admin-btn admin-btn--outline product-media-add" @click="addColorRow">
+ افزودن رنگ
</button>
</div>
@@ -666,7 +723,7 @@ function clearFilters() {
<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' ? 'ذخیره تغییرات' : 'ثبت محصول' }}
{{ modalMode === 'edit' ? 'ثبت تغییرات' : 'ثبت محصول' }}
</button>
</template>
</BaseModal>
@@ -727,12 +784,6 @@ function clearFilters() {
background: #eef1f0;
}
.color-variants {
display: flex;
flex-direction: column;
gap: 14px;
}
.color-variants__hint {
margin: 0 0 10px;
color: var(--color-text-muted);
@@ -740,41 +791,93 @@ function clearFilters() {
line-height: 1.6;
}
.general-images {
.general-images,
.color-variants {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.general-images__row {
display: flex;
flex-wrap: wrap;
gap: 12px;
.product-media-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: 10px;
gap: 12px 14px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-bg, #f7f8f7);
border-radius: 14px;
background: var(--color-elevated, #fff);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.color-variant {
display: flex;
flex-wrap: wrap;
gap: 12px;
.product-media-row--color {
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-bg, #f7f8f7);
}
.color-variant__fields {
flex: 1;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
.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>