Initialize the admin app with banner management for app sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:46 +03:30
co-authored by Cursor
commit d670921f74
104 changed files with 26137 additions and 0 deletions
+623
View File
@@ -0,0 +1,623 @@
<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 { catalogsApi, productsApi } 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 AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { fileToCatalogImage } from '@/utils/catalogImage'
const productList = ref<Product[]>([])
const catalogs = ref<Catalog[]>([])
const searchQuery = ref('')
const catalogFilter = 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 catalogFilterOptions = computed(() => [
{ value: 'all', label: 'همه کالیته‌ها' },
...catalogs.value.map((catalog) => ({
value: catalog.id,
label: `${catalog.code} — ${catalog.name}`,
})),
])
const catalogFormOptions = computed(() =>
catalogs.value.map((catalog) => ({
value: catalog.id,
label: `${catalog.code} — ${catalog.name} (${catalog.companyName})`,
})),
)
const emptyForm = () => ({
catalogId: catalogs.value[0]?.id ?? '',
name: '',
color: '',
productCode: '',
minMeterage: 100,
orderStep: 50,
price: null as number | null,
internalStock: 0,
isActive: true,
usageCategory: 'other',
season: 'all_season',
composition: '',
weaveType: '',
subtitle: '',
rollMeters: 50,
isNew: false,
colorsText: '',
imageUrls: [] as string[],
})
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 hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || catalogFilter.value !== 'all',
)
const filteredProducts = computed(() => {
let list = productList.value
if (catalogFilter.value !== 'all') {
list = list.filter((p) => p.catalogId === catalogFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(p) =>
p.name.includes(q) ||
p.color.includes(q) ||
p.productCode.includes(q) ||
p.catalogCode.includes(q),
)
}
return list
})
async function loadData() {
loading.value = true
try {
const [productsData, catalogsData] = await Promise.all([
productsApi.list(),
catalogsApi.list(),
])
productList.value = productsData
catalogs.value = catalogsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
function catalogMeta(catalogId: string) {
return catalogs.value.find((c) => c.id === catalogId)
}
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(product: Product) {
modalMode.value = 'edit'
editingId.value = product.id
form.catalogId = product.catalogId
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.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.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
form.isNew = (product as { isNew?: boolean }).isNew ?? false
form.colorsText = (product.colors ?? []).join(', ')
form.imageUrls = [...(product.imageUrls ?? [])]
formError.value = ''
}
function parseListText(value: string): string[] {
return value.split(',').map((part) => part.trim()).filter(Boolean)
}
const imageBusy = ref(false)
async function addProductImages(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]
input.value = ''
if (!files.length) return
imageBusy.value = true
formError.value = ''
try {
const next = await Promise.all(files.map((file) => fileToCatalogImage(file)))
form.imageUrls = [...form.imageUrls, ...next]
} catch (e) {
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
} finally {
imageBusy.value = false
}
}
function removeProductImage(index: number) {
form.imageUrls.splice(index, 1)
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitProduct() {
if (!isFormValid.value) {
formError.value = 'نام محصول، کالیته و حداقل متراژ الزامی هستند.'
return
}
const cat = catalogMeta(form.catalogId)
if (!cat) {
formError.value = 'کالیته معتبر انتخاب کنید.'
return
}
const catalogPayload = {
usageCategory: form.usageCategory,
season: form.season,
composition: form.composition.trim() || null,
weaveType: form.weaveType.trim() || null,
subtitle: form.subtitle.trim() || null,
rollMeters: form.rollMeters,
isNew: form.isNew,
colorsJson: parseListText(form.colorsText),
imageUrlsJson: form.imageUrls,
}
const productCode = form.productCode.trim() || `${cat.code}-NEW`
saving.value = true
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await productsApi.update(editingId.value, {
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
...catalogPayload,
})
const index = productList.value.findIndex((p) => p.id === editingId.value)
if (index !== -1) productList.value[index] = { ...productList.value[index]!, ...updated }
toast.success('اطلاعات محصول به‌روزرسانی شد.')
} else {
const created = await productsApi.create({
companyId: cat.companyId,
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
...catalogPayload,
})
productList.value.push(created)
toast.success('محصول جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره محصول انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await productsApi.setActive(id, isActive)
}
function clearFilters() {
searchQuery.value = ''
catalogFilter.value = 'all'
}
</script>
<template>
<div class="products-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">محصولات</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن محصول
</button>
</div>
<div class="products-page__filters admin-card">
<div class="products-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="products-page__search-input"
placeholder="جستجو در نام، رنگ، کد..."
/>
</div>
<AdminSelect
v-model="catalogFilter"
class="products-page__catalog-filter"
aria-label="فیلتر کالیته"
:options="catalogFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredProducts.length"
class="admin-card"
icon="products"
:title="hasActiveFilters ? 'نتیجه‌ای یافت نشد' : 'محصولی ثبت نشده'"
:description="
hasActiveFilters
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین محصول را ثبت کنید.'
"
>
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
پاک کردن فیلترها
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن محصول
</button>
</BaseEmptyState>
<div v-else class="products-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>تصویر</th>
<th>کد محصول</th>
<th>نام</th>
<th>رنگ</th>
<th>کالیته</th>
<th>شرکت</th>
<th class="numeric">حداقل متراژ</th>
<th class="numeric">ضریب</th>
<th class="numeric">قیمت</th>
<th class="numeric">موجودی</th>
<th class="status">وضعیت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="product in filteredProducts" :key="product.id">
<td>
<span
class="product-thumb"
:class="{ 'product-thumb--empty': !product.imageUrls?.[0] }"
:style="product.imageUrls?.[0] ? { backgroundImage: `url(${product.imageUrls[0]})` } : undefined"
/>
</td>
<td>{{ product.productCode }}</td>
<td>{{ product.name }}</td>
<td>{{ product.color }}</td>
<td>{{ product.catalogCode }}</td>
<td>{{ product.companyName }}</td>
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
<td class="numeric">{{ product.price ? formatCurrency(product.price) : '—' }}</td>
<td class="numeric">{{ formatNumber(product.internalStock) }} متر</td>
<td class="status">
<ActiveSwitchCell
:id="product.id"
v-model="product.isActive"
:entity-name="product.name"
entity-type="محصول"
:persist="persistActive"
/>
</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredProducts.length" class="compact-only admin-card-list">
<article v-for="product in filteredProducts" :key="product.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ product.name }}</p>
<div class="admin-card-item__header-actions">
<ActiveSwitchCell
:id="product.id"
v-model="product.isActive"
:entity-name="product.name"
entity-type="محصول"
:persist="persistActive"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
</div>
</div>
<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>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitProduct">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>کالیته <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.catalogId"
block
aria-label="انتخاب کالیته"
placeholder="انتخاب کالیته"
:options="catalogFormOptions"
/>
</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" />
</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>
<select id="product-usage" v-model="form.usageCategory" class="admin-input">
<option value="trousers">شلوار</option>
<option value="suit">کت‌وشلوار</option>
<option value="shirt">پیراهن</option>
<option value="other">سایر</option>
</select>
</div>
<div class="admin-form__field">
<label for="product-season">فصل</label>
<select id="product-season" v-model="form.season" class="admin-input">
<option value="summer">تابستان</option>
<option value="winter">زمستان</option>
<option value="all_season">چهارفصل</option>
</select>
</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-colors">رنگ‌ها (با ویرگول)</label>
<input
id="product-colors"
v-model="form.colorsText"
type="text"
class="admin-input"
placeholder="مثلاً: سرمه‌ای، خاکستری، بژ"
/>
</div>
<div class="admin-form__field">
<label>تصاویر محصول</label>
<div class="product-images">
<div
v-for="(src, index) in form.imageUrls"
:key="`${index}-${src.slice(0, 24)}`"
class="product-images__item"
>
<span class="product-images__thumb" :style="{ backgroundImage: `url(${src})` }" />
<button type="button" class="product-images__remove" @click="removeProductImage(index)">
حذف
</button>
</div>
<label class="product-images__add">
<input type="file" accept="image/*" multiple hidden :disabled="imageBusy" @change="addProductImages" />
{{ imageBusy ? '...' : '+ افزودن تصویر' }}
</label>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isNew" label="محصول جدید" />
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitProduct">
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت محصول' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.products-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.products-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.products-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.products-page__catalog-filter {
width: min(260px, 100%);
}
.products-page__table {
overflow: hidden;
}
.product-thumb {
display: block;
width: 44px;
height: 36px;
border-radius: 8px;
background: #e8eceb center / cover no-repeat;
}
.product-thumb--empty {
background: #eef1f0;
}
.product-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.product-images__item {
display: flex;
flex-direction: column;
gap: 6px;
width: 92px;
}
.product-images__thumb {
display: block;
width: 92px;
height: 72px;
border-radius: 10px;
background: #e8eceb center / cover no-repeat;
border: 1px solid var(--color-border);
}
.product-images__remove,
.product-images__add {
border: 1px dashed var(--color-border);
background: transparent;
border-radius: 8px;
padding: 6px 8px;
font-size: 0.75rem;
cursor: pointer;
text-align: center;
}
.product-images__add {
display: grid;
place-items: center;
min-width: 92px;
min-height: 72px;
color: var(--color-text-muted);
}
</style>