2026-09-09 17:08:46 +03:30
|
|
|
|
<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'
|
2026-09-13 15:39:08 +03:30
|
|
|
|
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
|
2026-09-09 17:08:46 +03:30
|
|
|
|
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,
|
2026-09-13 15:39:08 +03:30
|
|
|
|
imageUrl: null as string | null,
|
2026-09-09 17:08:46 +03:30
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-13 15:39:08 +03:30
|
|
|
|
form.imageUrl = catalog.imageUrl ?? null
|
2026-09-09 17:08:46 +03:30
|
|
|
|
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,
|
2026-09-13 15:39:08 +03:30
|
|
|
|
imageUrl: form.imageUrl,
|
2026-09-09 17:08:46 +03:30
|
|
|
|
})
|
|
|
|
|
|
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,
|
2026-09-13 15:39:08 +03:30
|
|
|
|
imageUrl: form.imageUrl,
|
2026-09-09 17:08:46 +03:30
|
|
|
|
})
|
|
|
|
|
|
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>
|
2026-09-13 15:39:08 +03:30
|
|
|
|
<th>تصویر</th>
|
2026-09-09 17:08:46 +03:30
|
|
|
|
<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">
|
2026-09-13 15:39:08 +03:30
|
|
|
|
<td>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="catalog-thumb"
|
|
|
|
|
|
:class="{ 'catalog-thumb--empty': !catalog.imageUrl }"
|
|
|
|
|
|
:style="catalog.imageUrl ? { backgroundImage: `url(${catalog.imageUrl})` } : undefined"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
2026-09-09 17:08:46 +03:30
|
|
|
|
<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">
|
2026-09-13 15:39:08 +03:30
|
|
|
|
<span
|
|
|
|
|
|
class="catalog-thumb"
|
|
|
|
|
|
:class="{ 'catalog-thumb--empty': !catalog.imageUrl }"
|
|
|
|
|
|
:style="catalog.imageUrl ? { backgroundImage: `url(${catalog.imageUrl})` } : undefined"
|
|
|
|
|
|
/>
|
2026-09-09 17:08:46 +03:30
|
|
|
|
<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>
|
2026-09-13 15:39:08 +03:30
|
|
|
|
|
|
|
|
|
|
<AdminImagePicker v-model="form.imageUrl" label="تصویر بنر کالیته" />
|
2026-09-09 17:08:46 +03:30
|
|
|
|
</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;
|
|
|
|
|
|
}
|
2026-09-13 15:39:08 +03:30
|
|
|
|
|
|
|
|
|
|
.catalog-thumb {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 72px;
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
background: #e8eceb center / cover no-repeat;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.catalog-thumb--empty {
|
|
|
|
|
|
background: #eef1f0;
|
|
|
|
|
|
}
|
2026-09-09 17:08:46 +03:30
|
|
|
|
</style>
|