change nademitex2

This commit is contained in:
2026-09-30 22:12:16 +03:30
parent ebfbeb7a88
commit 990883ae11
67 changed files with 602 additions and 1058 deletions
-397
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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>