change nademitex2
This commit is contained in:
@@ -1,397 +0,0 @@
|
||||
<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 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 AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
|
||||
import { catalogsApi, companiesApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Catalog, Company } from '@/types'
|
||||
|
||||
const catalogList = ref<Catalog[]>([])
|
||||
const companies = ref<Company[]>([])
|
||||
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(() =>
|
||||
companies.value.map((company) => ({ value: company.id, label: company.name })),
|
||||
)
|
||||
|
||||
const emptyForm = () => ({
|
||||
companyId: companies.value[0]?.id ?? '',
|
||||
code: '',
|
||||
name: '',
|
||||
displayOrder: catalogList.value.length + 1,
|
||||
isActive: true,
|
||||
imageUrl: null as string | null,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کالیته' : 'افزودن کالیته'))
|
||||
const isFormValid = computed(() => form.code.trim() && form.name.trim() && form.companyId)
|
||||
const hasActiveFilters = computed(
|
||||
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
|
||||
)
|
||||
|
||||
const filteredCatalogs = computed(() => {
|
||||
let list = catalogList.value
|
||||
if (companyFilter.value !== 'all') {
|
||||
list = list.filter((c) => c.companyId === companyFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter((c) => c.code.includes(q) || c.name.includes(q) || c.companyName.includes(q))
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [catalogsData, companiesData] = await Promise.all([
|
||||
catalogsApi.list(),
|
||||
companiesApi.list(),
|
||||
])
|
||||
catalogList.value = catalogsData
|
||||
companies.value = companiesData
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری کالیتهها انجام نشد.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
function companyName(companyId: string) {
|
||||
return companies.value.find((c) => c.id === companyId)?.name ?? '—'
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(catalog: Catalog) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = catalog.id
|
||||
form.companyId = catalog.companyId
|
||||
form.code = catalog.code
|
||||
form.name = catalog.name
|
||||
form.displayOrder = catalog.displayOrder
|
||||
form.isActive = catalog.isActive
|
||||
form.imageUrl = catalog.imageUrl ?? null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalMode.value = null
|
||||
editingId.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
async function submitCatalog() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'کد، نام و شرکت الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (modalMode.value === 'edit' && editingId.value) {
|
||||
const updated = await catalogsApi.update(editingId.value, {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
displayOrder: Number(form.displayOrder) || 1,
|
||||
isActive: form.isActive,
|
||||
imageUrl: form.imageUrl,
|
||||
})
|
||||
const index = catalogList.value.findIndex((c) => c.id === editingId.value)
|
||||
if (index !== -1) {
|
||||
catalogList.value[index] = {
|
||||
...catalogList.value[index]!,
|
||||
...updated,
|
||||
companyName: companyName(form.companyId),
|
||||
}
|
||||
}
|
||||
toast.success('اطلاعات کالیته بهروزرسانی شد.')
|
||||
} else {
|
||||
const created = await catalogsApi.create({
|
||||
companyId: form.companyId,
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
displayOrder: Number(form.displayOrder) || 1,
|
||||
isActive: form.isActive,
|
||||
imageUrl: form.imageUrl,
|
||||
})
|
||||
catalogList.value.push({
|
||||
...created,
|
||||
companyName: created.companyName || companyName(form.companyId),
|
||||
productCount: created.productCount ?? 0,
|
||||
})
|
||||
toast.success('کالیته جدید ثبت شد.')
|
||||
}
|
||||
closeModal()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ذخیره کالیته انجام نشد.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function persistActive(id: string, isActive: boolean) {
|
||||
await catalogsApi.setActive(id, isActive)
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
companyFilter.value = 'all'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="catalogs-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="catalogs-page__filters admin-card">
|
||||
<div class="catalogs-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="catalogs-page__search-input"
|
||||
placeholder="جستجو بر اساس کد یا نام..."
|
||||
/>
|
||||
</div>
|
||||
<AdminSelect
|
||||
v-model="companyFilter"
|
||||
class="catalogs-page__company-filter"
|
||||
aria-label="فیلتر شرکت"
|
||||
:options="companyFilterOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminPageSkeleton v-if="loading" variant="table" />
|
||||
<BaseEmptyState
|
||||
v-else-if="!filteredCatalogs.length"
|
||||
class="admin-card"
|
||||
icon="catalogs"
|
||||
: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="catalogs-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 class="numeric">ترتیب</th>
|
||||
<th class="numeric">تعداد محصول</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="catalog in filteredCatalogs" :key="catalog.id">
|
||||
<td>
|
||||
<span
|
||||
class="catalog-thumb"
|
||||
:class="{ 'catalog-thumb--empty': !catalog.imageUrl }"
|
||||
:style="catalog.imageUrl ? { backgroundImage: `url(${catalog.imageUrl})` } : undefined"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ catalog.code }}</td>
|
||||
<td>{{ catalog.name }}</td>
|
||||
<td>{{ catalog.companyName }}</td>
|
||||
<td class="numeric">{{ catalog.displayOrder }}</td>
|
||||
<td class="numeric">{{ catalog.productCount }}</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="catalog.id"
|
||||
v-model="catalog.isActive"
|
||||
:entity-name="catalog.name"
|
||||
entity-type="کالیته"
|
||||
:persist="persistActive"
|
||||
/>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
|
||||
ویرایش
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && filteredCatalogs.length" class="compact-only admin-card-list">
|
||||
<article v-for="catalog in filteredCatalogs" :key="catalog.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<span
|
||||
class="catalog-thumb"
|
||||
:class="{ 'catalog-thumb--empty': !catalog.imageUrl }"
|
||||
:style="catalog.imageUrl ? { backgroundImage: `url(${catalog.imageUrl})` } : undefined"
|
||||
/>
|
||||
<p class="admin-card-item__title">کالیته {{ catalog.code }} — {{ catalog.name }}</p>
|
||||
<ActiveSwitchCell
|
||||
:id="catalog.id"
|
||||
v-model="catalog.isActive"
|
||||
:entity-name="catalog.name"
|
||||
entity-type="کالیته"
|
||||
:persist="persistActive"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ catalog.companyName }}</span>
|
||||
<span>{{ catalog.productCount }} محصول</span>
|
||||
<span>ترتیب: {{ catalog.displayOrder }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitCatalog">
|
||||
<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>
|
||||
<AdminSelect
|
||||
v-model="form.companyId"
|
||||
block
|
||||
aria-label="انتخاب شرکت"
|
||||
placeholder="انتخاب شرکت"
|
||||
:options="companyFormOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-code">کد کالیته <span class="admin-form__required">*</span></label>
|
||||
<input id="catalog-code" v-model="form.code" type="text" class="admin-input" placeholder="377" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-name">نام کالیته <span class="admin-form__required">*</span></label>
|
||||
<input id="catalog-name" v-model="form.name" type="text" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-order">ترتیب نمایش</label>
|
||||
<input id="catalog-order" v-model.number="form.displayOrder" type="number" min="1" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminImagePicker v-model="form.imageUrl" label="تصویر بنر کالیته" />
|
||||
</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="submitCatalog">
|
||||
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کالیته' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalogs-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.catalogs-page__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.catalogs-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.catalogs-page__company-filter {
|
||||
width: min(220px, 100%);
|
||||
}
|
||||
|
||||
.catalogs-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.catalog-thumb {
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background: #e8eceb center / cover no-repeat;
|
||||
}
|
||||
|
||||
.catalog-thumb--empty {
|
||||
background: #eef1f0;
|
||||
}
|
||||
</style>
|
||||
@@ -164,7 +164,7 @@ function submitAddProduct() {
|
||||
items.value.push({
|
||||
id: `new-${crypto.randomUUID().slice(0, 8)}`,
|
||||
productId: product.id,
|
||||
catalogCode: product.catalogCode,
|
||||
catalogCode: product.catalogCode ?? product.productCode,
|
||||
productName: product.name,
|
||||
color: product.color || '—',
|
||||
requestedMeterage: form.requestedMeterage,
|
||||
@@ -321,7 +321,7 @@ async function sendInvoice() {
|
||||
<th>رنگ / نوع</th>
|
||||
<th>متراژ درخواستی</th>
|
||||
<th>متراژ تأیید شده</th>
|
||||
<th>قیمت تأمینی (واحد)</th>
|
||||
<th>قیمت هر متر (تومان)</th>
|
||||
<th>مبلغ کل (تومان)</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
@@ -386,7 +386,7 @@ async function sendInvoice() {
|
||||
<input v-model.number="item.confirmedMeterage" type="number" class="admin-input" />
|
||||
</label>
|
||||
<label>
|
||||
قیمت تأمینی
|
||||
قیمت هر متر (تومان)
|
||||
<AdminCurrencyInput v-model="item.supplyPrice" placeholder="—" />
|
||||
</label>
|
||||
<p v-if="item.totalAmount" class="pricing-page__line-total">
|
||||
|
||||
@@ -137,7 +137,7 @@ function addDraftProduct() {
|
||||
const added = addFormItem({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
catalogCode: product.catalogCode,
|
||||
catalogCode: product.catalogCode ?? product.productCode,
|
||||
meterage,
|
||||
})
|
||||
if (!added) {
|
||||
|
||||
@@ -239,8 +239,8 @@ function onCashReceiptAction() {
|
||||
<th>محصول</th>
|
||||
<th>رنگ</th>
|
||||
<th>متراژ</th>
|
||||
<th>قیمت</th>
|
||||
<th>مبلغ</th>
|
||||
<th>قیمت هر متر</th>
|
||||
<th>مبلغ کل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
+162
-100
@@ -8,16 +8,16 @@ 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, catalogsApi, productsApi, settingsApi, type Brand } from '@/services/api'
|
||||
import { brandsApi, companiesApi, productsApi, settingsApi, type Brand } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatCurrency, formatNumber } from '@/utils/format'
|
||||
import type { Catalog, Product } from '@/types'
|
||||
import type { Company, Product } from '@/types'
|
||||
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
|
||||
import { toCssBackgroundUrl } from '@/utils/catalogImage'
|
||||
|
||||
const productList = ref<Product[]>([])
|
||||
const catalogs = ref<Catalog[]>([])
|
||||
const companies = ref<Company[]>([])
|
||||
const brands = ref<Brand[]>([])
|
||||
const usageOptions = ref<Array<{ value: string; label: string }>>([
|
||||
{ value: 'trousers', label: 'شلوار' },
|
||||
@@ -31,7 +31,7 @@ const seasonOptions = ref<Array<{ value: string; label: string }>>([
|
||||
{ value: 'all_season', label: 'چهارفصل' },
|
||||
])
|
||||
const searchQuery = ref('')
|
||||
const catalogFilter = ref('all')
|
||||
const companyFilter = ref('all')
|
||||
const loading = ref(true)
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
@@ -39,19 +39,21 @@ const formError = ref('')
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const catalogFilterOptions = computed(() => [
|
||||
{ value: 'all', label: 'همه کالیتهها' },
|
||||
...catalogs.value.map((catalog) => ({
|
||||
value: catalog.id,
|
||||
label: `${catalog.code} — ${catalog.name}`,
|
||||
const companyFilterOptions = computed(() => [
|
||||
{ value: 'all', label: 'همه شرکتها' },
|
||||
...companies.value.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
])
|
||||
|
||||
const catalogFormOptions = computed(() =>
|
||||
catalogs.value.map((catalog) => ({
|
||||
value: catalog.id,
|
||||
label: `${catalog.code} — ${catalog.name} (${catalog.companyName})`,
|
||||
})),
|
||||
const companyFormOptions = computed(() =>
|
||||
companies.value
|
||||
.filter((c) => c.isActive)
|
||||
.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
)
|
||||
|
||||
const brandFormOptions = computed(() => [
|
||||
@@ -62,7 +64,7 @@ const brandFormOptions = computed(() => [
|
||||
])
|
||||
|
||||
const emptyForm = () => ({
|
||||
catalogId: catalogs.value[0]?.id ?? '',
|
||||
companyId: companies.value[0]?.id ?? '',
|
||||
brandId: '',
|
||||
name: '',
|
||||
color: '',
|
||||
@@ -79,6 +81,7 @@ const emptyForm = () => ({
|
||||
subtitle: '',
|
||||
rollMeters: 50,
|
||||
isNew: false,
|
||||
generalImages: [] as string[],
|
||||
colorRows: [{ id: crypto.randomUUID(), name: '', imageUrl: '' }] as Array<{
|
||||
id: string
|
||||
name: string
|
||||
@@ -90,15 +93,15 @@ const form = reactive(emptyForm())
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
|
||||
const isFormValid = computed(() => form.name.trim() && form.catalogId && form.minMeterage > 0)
|
||||
const isFormValid = computed(() => form.name.trim() && form.companyId && form.minMeterage > 0)
|
||||
const hasActiveFilters = computed(
|
||||
() => searchQuery.value.trim().length > 0 || catalogFilter.value !== 'all',
|
||||
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
|
||||
)
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
let list = productList.value
|
||||
if (catalogFilter.value !== 'all') {
|
||||
list = list.filter((p) => p.catalogId === catalogFilter.value)
|
||||
if (companyFilter.value !== 'all') {
|
||||
list = list.filter((p) => p.companyId === companyFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
@@ -107,7 +110,7 @@ const filteredProducts = computed(() => {
|
||||
p.name.includes(q) ||
|
||||
p.color.includes(q) ||
|
||||
p.productCode.includes(q) ||
|
||||
p.catalogCode.includes(q),
|
||||
(p.catalogCode ?? p.productCode).includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
@@ -116,14 +119,14 @@ const filteredProducts = computed(() => {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [productsData, catalogsData, brandsData, settings] = await Promise.all([
|
||||
const [productsData, companiesData, brandsData, settings] = await Promise.all([
|
||||
productsApi.list(),
|
||||
catalogsApi.list(),
|
||||
companiesApi.list(),
|
||||
brandsApi.list(),
|
||||
settingsApi.get().catch(() => null),
|
||||
])
|
||||
productList.value = productsData
|
||||
catalogs.value = catalogsData
|
||||
companies.value = companiesData
|
||||
brands.value = brandsData
|
||||
if (settings?.categoryDefsJson?.usage?.length) {
|
||||
usageOptions.value = [
|
||||
@@ -151,10 +154,6 @@ onMounted(() => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
function catalogMeta(catalogId: string) {
|
||||
return catalogs.value.find((c) => c.id === catalogId)
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
@@ -162,10 +161,15 @@ function openAddModal() {
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(product: Product) {
|
||||
async function openEditModal(product: Product) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = product.id
|
||||
form.catalogId = product.catalogId
|
||||
try {
|
||||
product = await productsApi.getById(product.id)
|
||||
} catch {
|
||||
/* از ردیف جدول */
|
||||
}
|
||||
form.companyId = product.companyId ?? companies.value[0]?.id ?? ''
|
||||
form.brandId = product.brandId ?? ''
|
||||
form.name = product.name
|
||||
form.color = product.color
|
||||
@@ -182,36 +186,20 @@ function openEditModal(product: Product) {
|
||||
form.subtitle = (product as { subtitle?: string }).subtitle ?? ''
|
||||
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 colorImages = product.colorImages ?? []
|
||||
const gallery = [...(product.imageUrls ?? [])]
|
||||
if (colors.length) {
|
||||
form.colorRows = colors.map((name, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
imageUrl: colorImages[index] || gallery[index] || '',
|
||||
}))
|
||||
const used = new Set(form.colorRows.map((row) => row.imageUrl).filter(Boolean))
|
||||
for (const url of gallery) {
|
||||
if (!used.has(url)) {
|
||||
form.colorRows.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: `رنگ ${form.colorRows.length + 1}`,
|
||||
imageUrl: url,
|
||||
})
|
||||
used.add(url)
|
||||
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 if (gallery.length) {
|
||||
form.colorRows = gallery.map((url, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: index === 0 ? product.color || '' : `رنگ ${index + 1}`,
|
||||
imageUrl: url,
|
||||
}))
|
||||
})
|
||||
} else {
|
||||
form.colorRows = [
|
||||
{ id: crypto.randomUUID(), name: product.color || '', imageUrl: '' },
|
||||
]
|
||||
form.colorRows = [{ id: crypto.randomUUID(), name: product.color || '', imageUrl: '' }]
|
||||
}
|
||||
formError.value = ''
|
||||
}
|
||||
@@ -222,23 +210,39 @@ function addColorRow() {
|
||||
|
||||
function removeColorRow(index: number) {
|
||||
if (form.colorRows.length <= 1) {
|
||||
form.colorRows[0] = { id: form.colorRows[0]?.id ?? crypto.randomUUID(), name: '', imageUrl: '' }
|
||||
form.colorRows[0] = {
|
||||
id: form.colorRows[0]?.id ?? crypto.randomUUID(),
|
||||
name: '',
|
||||
imageUrl: '',
|
||||
}
|
||||
return
|
||||
}
|
||||
form.colorRows.splice(index, 1)
|
||||
}
|
||||
|
||||
function buildProductMedia() {
|
||||
const colorEntries: Array<{ name: string; imageUrl?: string }> = []
|
||||
const imageUrls: string[] = []
|
||||
const seen = new Set<string>()
|
||||
function addGeneralImageSlot() {
|
||||
form.generalImages.push('')
|
||||
}
|
||||
|
||||
const pushImage = (url?: string) => {
|
||||
const trimmed = url?.trim()
|
||||
if (!trimmed || seen.has(trimmed)) return
|
||||
seen.add(trimmed)
|
||||
imageUrls.push(trimmed)
|
||||
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
|
||||
@@ -247,10 +251,12 @@ function buildProductMedia() {
|
||||
(index === 0 ? form.color.trim() : '') ||
|
||||
(imageUrl ? `رنگ ${index + 1}` : '')
|
||||
if (!name && !imageUrl) return
|
||||
if (name) {
|
||||
colorEntries.push({ name, imageUrl })
|
||||
}
|
||||
pushImage(imageUrl)
|
||||
if (!name) return
|
||||
colorEntries.push({
|
||||
name,
|
||||
imageUrls: imageUrl ? [imageUrl] : [],
|
||||
imageUrl,
|
||||
})
|
||||
})
|
||||
|
||||
return { colorEntries, imageUrls }
|
||||
@@ -264,13 +270,13 @@ function closeModal() {
|
||||
|
||||
async function submitProduct() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام محصول، کالیته و حداقل متراژ الزامی هستند.'
|
||||
formError.value = 'نام محصول، شرکت و حداقل متراژ الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const cat = catalogMeta(form.catalogId)
|
||||
if (!cat) {
|
||||
formError.value = 'کالیته معتبر انتخاب کنید.'
|
||||
const company = companies.value.find((c) => c.id === form.companyId)
|
||||
if (!company) {
|
||||
formError.value = 'شرکت معتبر انتخاب کنید.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,12 +292,11 @@ async function submitProduct() {
|
||||
colorsJson: colorEntries,
|
||||
imageUrlsJson: imageUrls,
|
||||
}
|
||||
const productCode = form.productCode.trim() || `${cat.code}-NEW`
|
||||
const productCode = form.productCode.trim() || `PRD-${Date.now()}`
|
||||
saving.value = true
|
||||
try {
|
||||
if (modalMode.value === 'edit' && editingId.value) {
|
||||
const updated = await productsApi.update(editingId.value, {
|
||||
catalogId: form.catalogId,
|
||||
await productsApi.update(editingId.value, {
|
||||
name: form.name.trim(),
|
||||
color: form.color.trim() || colorEntries[0]?.name || '—',
|
||||
code: productCode,
|
||||
@@ -301,16 +306,15 @@ async function submitProduct() {
|
||||
price: form.price,
|
||||
internalStock: form.internalStock,
|
||||
isActive: form.isActive,
|
||||
brandId: form.brandId || null,
|
||||
...catalogPayload,
|
||||
})
|
||||
const fresh = await productsApi.getById(editingId.value)
|
||||
const index = productList.value.findIndex((p) => p.id === editingId.value)
|
||||
if (index !== -1) productList.value[index] = { ...productList.value[index]!, ...updated }
|
||||
if (index !== -1) productList.value[index] = fresh
|
||||
toast.success('اطلاعات محصول بهروزرسانی شد.')
|
||||
} else {
|
||||
const created = await productsApi.create({
|
||||
companyId: cat.companyId,
|
||||
catalogId: form.catalogId,
|
||||
companyId: company.id,
|
||||
name: form.name.trim(),
|
||||
color: form.color.trim() || colorEntries[0]?.name || '—',
|
||||
code: productCode,
|
||||
@@ -343,7 +347,7 @@ async function persistActive(id: string, isActive: boolean) {
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
catalogFilter.value = 'all'
|
||||
companyFilter.value = 'all'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -369,10 +373,10 @@ function clearFilters() {
|
||||
/>
|
||||
</div>
|
||||
<AdminSelect
|
||||
v-model="catalogFilter"
|
||||
v-model="companyFilter"
|
||||
class="products-page__catalog-filter"
|
||||
aria-label="فیلتر کالیته"
|
||||
:options="catalogFilterOptions"
|
||||
aria-label="فیلتر شرکت"
|
||||
:options="companyFilterOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -406,11 +410,11 @@ function clearFilters() {
|
||||
<th>نام</th>
|
||||
<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="numeric">موجودی</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th class="actions">عملیات</th>
|
||||
@@ -429,11 +433,13 @@ function clearFilters() {
|
||||
<td>{{ product.name }}</td>
|
||||
<td>{{ product.color }}</td>
|
||||
<td>{{ product.brandName || '—' }}</td>
|
||||
<td>{{ product.catalogCode }}</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>
|
||||
<td class="numeric">{{ product.price ? formatCurrency(product.price) : '—' }}</td>
|
||||
<td class="numeric">
|
||||
{{ product.price ? `${formatCurrency(product.price)} / متر` : '—' }}
|
||||
</td>
|
||||
<td class="numeric">{{ formatNumber(product.internalStock) }} متر</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
@@ -475,8 +481,8 @@ function clearFilters() {
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ product.productCode }}</span>
|
||||
<span>{{ product.color }}</span>
|
||||
<span>کالیته {{ product.catalogCode }}</span>
|
||||
<span>{{ product.price ? formatCurrency(product.price) : 'بدون قیمت' }}</span>
|
||||
<span dir="ltr">کد {{ product.productCode }}</span>
|
||||
<span>{{ product.price ? `${formatCurrency(product.price)} / متر` : 'بدون قیمت' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -486,13 +492,13 @@ 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>شرکت <span class="admin-form__required">*</span></label>
|
||||
<AdminSelect
|
||||
v-model="form.catalogId"
|
||||
v-model="form.companyId"
|
||||
block
|
||||
aria-label="انتخاب کالیته"
|
||||
placeholder="انتخاب کالیته"
|
||||
:options="catalogFormOptions"
|
||||
aria-label="انتخاب شرکت"
|
||||
placeholder="انتخاب شرکت"
|
||||
:options="companyFormOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -536,8 +542,11 @@ function clearFilters() {
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="product-price">قیمت (تومان)</label>
|
||||
<AdminCurrencyInput id="product-price" v-model="form.price" />
|
||||
<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>
|
||||
@@ -587,15 +596,44 @@ function clearFilters() {
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label>رنگها و تصویر هر رنگ</label>
|
||||
<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"
|
||||
>
|
||||
<AdminImagePicker
|
||||
:model-value="url || null"
|
||||
@update:model-value="form.generalImages[gIdx] = $event ?? ''"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--ghost admin-btn--xs"
|
||||
@click="removeGeneralImage(gIdx)"
|
||||
>
|
||||
حذف تصویر
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="addGeneralImageSlot">
|
||||
+ افزودن تصویر عمومی
|
||||
</button>
|
||||
</div>
|
||||
</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="color-variant">
|
||||
<AdminImagePicker
|
||||
:model-value="row.imageUrl || null"
|
||||
:show-remove="false"
|
||||
@update:model-value="row.imageUrl = $event ?? ''"
|
||||
/>
|
||||
<div class="color-variant__fields">
|
||||
@@ -666,6 +704,13 @@ function clearFilters() {
|
||||
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;
|
||||
}
|
||||
@@ -695,6 +740,23 @@ function clearFilters() {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.general-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.general-images__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg, #f7f8f7);
|
||||
}
|
||||
|
||||
.color-variant {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -204,7 +204,7 @@ async function sendBroadcast() {
|
||||
</label>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="agent-min-price">حداقل قیمت واحد واسطه (تومان)</label>
|
||||
<label for="agent-min-price">حداقل قیمت هر متر واسطه (تومان)</label>
|
||||
<input
|
||||
id="agent-min-price"
|
||||
v-model.number="settings.agentMinUnitPrice"
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import { stockAlertsApi, type StockAlert } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const list = ref<StockAlert[]>([])
|
||||
const loading = ref(true)
|
||||
const toast = useToast()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await stockAlertsApi.list()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(id: string) {
|
||||
try {
|
||||
await stockAlertsApi.notify(id)
|
||||
toast.success('اعلان موجودی ارسال شد')
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ارسال ناموفق بود')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<header class="admin-page__head">
|
||||
<div>
|
||||
<h1>درخواست خبر موجودی</h1>
|
||||
<p>مشتریانی که برای محصول ناموجود درخواست اطلاع دادهاند</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AdminPageSkeleton v-if="loading" variant="table" />
|
||||
<BaseEmptyState v-else-if="!list.length" title="درخواستی فعال نیست" />
|
||||
|
||||
<table v-else class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>مشتری</th>
|
||||
<th>محصول</th>
|
||||
<th>موجودی فعلی</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in list" :key="row.id">
|
||||
<td>
|
||||
{{ row.customerName }}
|
||||
<span class="muted" dir="ltr">{{ row.customerPhone }}</span>
|
||||
</td>
|
||||
<td>{{ row.productName }} <span class="muted" dir="ltr">({{ row.productCode }})</span></td>
|
||||
<td>{{ row.stock ?? '—' }}</td>
|
||||
<td>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="notify(row.id)">ارسال اعلان</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page__head { margin-bottom: 20px; }
|
||||
.admin-page__head h1 { margin: 0 0 4px; font-size: 1.25rem; }
|
||||
.admin-page__head p { margin: 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { text-align: right; padding: 10px 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.muted { color: var(--color-text-muted); font-size: 0.75rem; margin-right: 6px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user