8/7/405
This commit is contained in:
@@ -86,7 +86,7 @@ onMounted(() => {
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>شناسه لاتین</th>
|
||||
<th>مشتریان</th>
|
||||
<th>محصولات</th>
|
||||
<th>فعال</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -95,7 +95,7 @@ onMounted(() => {
|
||||
<tr v-for="b in list" :key="b.id">
|
||||
<td>{{ b.name }}</td>
|
||||
<td dir="ltr">{{ b.slug || '—' }}</td>
|
||||
<td>{{ b.customersCount ?? 0 }}</td>
|
||||
<td>{{ b.productsCount ?? 0 }}</td>
|
||||
<td>{{ b.isActive ? 'بله' : 'خیر' }}</td>
|
||||
<td><button type="button" class="link" @click="openEdit(b)">ویرایش</button></td>
|
||||
</tr>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, customersApi, levelsApi, type Brand, type CustomerLevel } from '@/services/api'
|
||||
import { customersApi, levelsApi, type CustomerLevel } from '@/services/api'
|
||||
import CustomerSuggestionsPanel from './CustomerSuggestionsPanel.vue'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -18,7 +18,6 @@ import type { Customer, CustomerType } from '@/types'
|
||||
const customerList = ref<Customer[]>([])
|
||||
const levels = ref<CustomerLevel[]>([])
|
||||
const agents = ref<Customer[]>([])
|
||||
const brands = ref<Brand[]>([])
|
||||
const searchQuery = ref('')
|
||||
const roleTab = ref<'customer' | 'agent'>('customer')
|
||||
const loading = ref(true)
|
||||
@@ -84,7 +83,6 @@ const emptyForm = () => ({
|
||||
isActive: true,
|
||||
levelDefId: '' as string,
|
||||
assignedAgentId: '' as string,
|
||||
brandIds: [] as string[],
|
||||
appRole: 'customer' as 'customer' | 'agent',
|
||||
agentCode: '',
|
||||
})
|
||||
@@ -109,14 +107,6 @@ const filteredCustomers = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function toggleBrand(brandId: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!form.brandIds.includes(brandId)) form.brandIds.push(brandId)
|
||||
} else {
|
||||
form.brandIds = form.brandIds.filter((id) => id !== brandId)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -143,14 +133,12 @@ function setRoleTab(tab: 'customer' | 'agent') {
|
||||
|
||||
async function loadLookups() {
|
||||
try {
|
||||
const [levelsData, agentsData, brandsData] = await Promise.all([
|
||||
const [levelsData, agentsData] = await Promise.all([
|
||||
levelsApi.list(),
|
||||
customersApi.list(undefined, 'agent'),
|
||||
brandsApi.list(),
|
||||
])
|
||||
levels.value = levelsData
|
||||
agents.value = agentsData
|
||||
brands.value = brandsData
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری اطلاعات کمکی انجام نشد.')
|
||||
}
|
||||
@@ -180,7 +168,6 @@ function openEditModal(customer: Customer) {
|
||||
form.isActive = customer.isActive
|
||||
form.levelDefId = customer.levelDefId ?? ''
|
||||
form.assignedAgentId = customer.assignedAgentId ?? ''
|
||||
form.brandIds = [...(customer.brandIds ?? [])]
|
||||
form.appRole = customer.appRole === 'agent' ? 'agent' : 'customer'
|
||||
form.agentCode = customer.agentCode ?? ''
|
||||
formError.value = ''
|
||||
@@ -207,7 +194,6 @@ async function submitCustomer() {
|
||||
isActive: form.isActive,
|
||||
levelDefId: form.levelDefId || null,
|
||||
assignedAgentId: form.assignedAgentId || null,
|
||||
brandIds: [...form.brandIds],
|
||||
appRole: form.appRole,
|
||||
agentCode: form.appRole === 'agent' ? form.agentCode.trim() || null : null,
|
||||
}
|
||||
@@ -483,20 +469,6 @@ function clearSearch() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="brands.length" class="admin-form__field">
|
||||
<label>برندها</label>
|
||||
<div class="customers-page__brands">
|
||||
<label v-for="brand in brands" :key="brand.id" class="customers-page__brand">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.brandIds.includes(brand.id)"
|
||||
@change="toggleBrand(brand.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ brand.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
@@ -570,23 +542,4 @@ function clearSearch() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.customers-page__brands {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.customers-page__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.customers-page__brand input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<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 BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import { internalMessagesApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { InternalMessage } from '@/types'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const list = ref<InternalMessage[]>([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const modalOpen = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
const form = reactive({
|
||||
senderName: '',
|
||||
subject: '',
|
||||
preview: '',
|
||||
link: '',
|
||||
})
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
editingId.value ? 'ویرایش پیام داخلی' : 'پیام داخلی جدید',
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await internalMessagesApi.list()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری پیامها ناموفق بود.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
senderName: auth.fullName || 'پشتیبانی',
|
||||
subject: '',
|
||||
preview: '',
|
||||
link: '',
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: InternalMessage) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
senderName: row.senderName,
|
||||
subject: row.subject,
|
||||
preview: row.preview,
|
||||
link: row.link || '',
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.senderName.trim() || !form.subject.trim() || !form.preview.trim()) {
|
||||
toast.error('فرستنده، موضوع و متن پیام الزامی است.')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const payload = {
|
||||
senderName: form.senderName.trim(),
|
||||
subject: form.subject.trim(),
|
||||
preview: form.preview.trim(),
|
||||
link: form.link.trim() || null,
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await internalMessagesApi.update(editingId.value, payload)
|
||||
toast.success('پیام بهروزرسانی شد.')
|
||||
} else {
|
||||
await internalMessagesApi.create(payload)
|
||||
toast.success('پیام برای همه اپراتورها ارسال شد.')
|
||||
}
|
||||
modalOpen.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ذخیره پیام ناموفق بود.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: InternalMessage) {
|
||||
if (!window.confirm(`پیام «${row.subject}» حذف شود؟`)) return
|
||||
try {
|
||||
await internalMessagesApi.remove(row.id)
|
||||
toast.success('پیام حذف شد.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'حذف پیام ناموفق بود.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="internal-messages-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">پیامهای داخلی</h1>
|
||||
<p class="admin-page-header__sub">
|
||||
پیام برای تیم پشتیبانی در آیکن نامه بالای پنل نمایش داده میشود. با کلیک روی پیام، در صورت
|
||||
تعیین لینک به همان صفحه میروید.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAdd">پیام جدید</button>
|
||||
</div>
|
||||
|
||||
<AdminPageSkeleton v-if="loading" variant="table" />
|
||||
<BaseEmptyState
|
||||
v-else-if="!list.length"
|
||||
title="پیامی ثبت نشده"
|
||||
description="اولین پیام داخلی را برای تیم ارسال کنید."
|
||||
>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAdd">
|
||||
پیام جدید
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="admin-card">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>موضوع</th>
|
||||
<th>فرستنده</th>
|
||||
<th>خلاصه</th>
|
||||
<th>لینک</th>
|
||||
<th>زمان</th>
|
||||
<th>خواندهشده</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in list" :key="row.id">
|
||||
<td><strong>{{ row.subject }}</strong></td>
|
||||
<td>{{ row.senderName }}</td>
|
||||
<td class="preview-cell">{{ row.preview }}</td>
|
||||
<td dir="ltr" class="link-cell">{{ row.link || '—' }}</td>
|
||||
<td>{{ formatDateTime(row.sentAt) }}</td>
|
||||
<td>{{ row.isRead ? 'بله' : 'خیر' }}</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEdit(row)">
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="remove(row)">
|
||||
حذف
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="modalOpen" :title="modalTitle" size="md" @close="modalOpen = false">
|
||||
<label class="field">
|
||||
نام فرستنده
|
||||
<input v-model="form.senderName" type="text" class="admin-input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
موضوع
|
||||
<input v-model="form.subject" type="text" class="admin-input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
متن پیام (خلاصه در لیست نامه)
|
||||
<textarea v-model="form.preview" class="admin-input" rows="4" />
|
||||
</label>
|
||||
<label class="field">
|
||||
لینک (اختیاری)
|
||||
<input
|
||||
v-model="form.link"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
dir="ltr"
|
||||
placeholder="/orders یا /settings"
|
||||
/>
|
||||
</label>
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="modalOpen = false">انصراف</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page-header__sub {
|
||||
margin: 6px 0 0;
|
||||
max-width: 52rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview-cell {
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.link-cell {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -8,16 +8,17 @@ 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, settingsApi } from '@/services/api'
|
||||
import { brandsApi, catalogsApi, 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 AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
|
||||
import { fileToCatalogImage, toCssBackgroundUrl } from '@/utils/catalogImage'
|
||||
import { toCssBackgroundUrl } from '@/utils/catalogImage'
|
||||
|
||||
const productList = ref<Product[]>([])
|
||||
const catalogs = ref<Catalog[]>([])
|
||||
const brands = ref<Brand[]>([])
|
||||
const usageOptions = ref<Array<{ value: string; label: string }>>([
|
||||
{ value: 'trousers', label: 'شلوار' },
|
||||
{ value: 'suit', label: 'کتوشلوار' },
|
||||
@@ -53,8 +54,16 @@ const catalogFormOptions = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const brandFormOptions = computed(() => [
|
||||
{ value: '', label: 'بدون برند' },
|
||||
...brands.value
|
||||
.filter((b) => b.isActive)
|
||||
.map((b) => ({ value: b.id, label: b.name })),
|
||||
])
|
||||
|
||||
const emptyForm = () => ({
|
||||
catalogId: catalogs.value[0]?.id ?? '',
|
||||
brandId: '',
|
||||
name: '',
|
||||
color: '',
|
||||
productCode: '',
|
||||
@@ -70,8 +79,11 @@ const emptyForm = () => ({
|
||||
subtitle: '',
|
||||
rollMeters: 50,
|
||||
isNew: false,
|
||||
colorRows: [{ name: '', imageUrl: '' }] as Array<{ name: string; imageUrl: string }>,
|
||||
extraImageUrls: [] as string[],
|
||||
colorRows: [{ id: crypto.randomUUID(), name: '', imageUrl: '' }] as Array<{
|
||||
id: string
|
||||
name: string
|
||||
imageUrl: string
|
||||
}>,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
@@ -104,13 +116,15 @@ const filteredProducts = computed(() => {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [productsData, catalogsData, settings] = await Promise.all([
|
||||
const [productsData, catalogsData, brandsData, settings] = await Promise.all([
|
||||
productsApi.list(),
|
||||
catalogsApi.list(),
|
||||
brandsApi.list(),
|
||||
settingsApi.get().catch(() => null),
|
||||
])
|
||||
productList.value = productsData
|
||||
catalogs.value = catalogsData
|
||||
brands.value = brandsData
|
||||
if (settings?.categoryDefsJson?.usage?.length) {
|
||||
usageOptions.value = [
|
||||
...settings.categoryDefsJson.usage.map((item) => ({
|
||||
@@ -152,6 +166,7 @@ function openEditModal(product: Product) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = product.id
|
||||
form.catalogId = product.catalogId
|
||||
form.brandId = product.brandId ?? ''
|
||||
form.name = product.name
|
||||
form.color = product.color
|
||||
form.productCode = product.productCode
|
||||
@@ -172,53 +187,47 @@ function openEditModal(product: Product) {
|
||||
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))
|
||||
form.extraImageUrls = gallery.filter((url) => !used.has(url))
|
||||
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)
|
||||
}
|
||||
}
|
||||
} 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 = [{ name: product.color || '', imageUrl: gallery[0] || '' }]
|
||||
form.extraImageUrls = gallery.slice(1)
|
||||
form.colorRows = [
|
||||
{ id: crypto.randomUUID(), name: product.color || '', imageUrl: '' },
|
||||
]
|
||||
}
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function addColorRow() {
|
||||
form.colorRows.push({ name: '', imageUrl: '' })
|
||||
form.colorRows.push({ id: crypto.randomUUID(), name: '', imageUrl: '' })
|
||||
}
|
||||
|
||||
function removeColorRow(index: number) {
|
||||
if (form.colorRows.length <= 1) {
|
||||
form.colorRows[0] = { name: '', imageUrl: '' }
|
||||
form.colorRows[0] = { id: form.colorRows[0]?.id ?? crypto.randomUUID(), name: '', imageUrl: '' }
|
||||
return
|
||||
}
|
||||
form.colorRows.splice(index, 1)
|
||||
}
|
||||
|
||||
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.extraImageUrls = [...form.extraImageUrls, ...next]
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
|
||||
} finally {
|
||||
imageBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeProductImage(index: number) {
|
||||
form.extraImageUrls.splice(index, 1)
|
||||
}
|
||||
|
||||
function buildProductMedia() {
|
||||
const colorEntries: Array<{ name: string; imageUrl?: string }> = []
|
||||
const imageUrls: string[] = []
|
||||
@@ -244,8 +253,6 @@ function buildProductMedia() {
|
||||
pushImage(imageUrl)
|
||||
})
|
||||
|
||||
form.extraImageUrls.forEach((url) => pushImage(url))
|
||||
|
||||
return { colorEntries, imageUrls }
|
||||
}
|
||||
|
||||
@@ -294,6 +301,7 @@ async function submitProduct() {
|
||||
price: form.price,
|
||||
internalStock: form.internalStock,
|
||||
isActive: form.isActive,
|
||||
brandId: form.brandId || null,
|
||||
...catalogPayload,
|
||||
})
|
||||
const index = productList.value.findIndex((p) => p.id === editingId.value)
|
||||
@@ -312,6 +320,7 @@ async function submitProduct() {
|
||||
price: form.price,
|
||||
internalStock: form.internalStock,
|
||||
isActive: form.isActive,
|
||||
brandId: form.brandId || null,
|
||||
...catalogPayload,
|
||||
})
|
||||
productList.value.push(created)
|
||||
@@ -396,6 +405,7 @@ function clearFilters() {
|
||||
<th>کد محصول</th>
|
||||
<th>نام</th>
|
||||
<th>رنگ</th>
|
||||
<th>برند</th>
|
||||
<th>کالیته</th>
|
||||
<th>شرکت</th>
|
||||
<th class="numeric">حداقل متراژ</th>
|
||||
@@ -418,6 +428,7 @@ function clearFilters() {
|
||||
<td>{{ product.productCode }}</td>
|
||||
<td>{{ product.name }}</td>
|
||||
<td>{{ product.color }}</td>
|
||||
<td>{{ product.brandName || '—' }}</td>
|
||||
<td>{{ product.catalogCode }}</td>
|
||||
<td>{{ product.companyName }}</td>
|
||||
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
|
||||
@@ -485,6 +496,17 @@ function clearFilters() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label>برند</label>
|
||||
<AdminSelect
|
||||
v-model="form.brandId"
|
||||
block
|
||||
aria-label="برند محصول"
|
||||
placeholder="انتخاب برند"
|
||||
:options="brandFormOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
|
||||
@@ -571,7 +593,7 @@ function clearFilters() {
|
||||
تصویر با زدن «ذخیره تغییرات» روی سرور ثبت میشود.
|
||||
</p>
|
||||
<div class="color-variants">
|
||||
<div v-for="(row, index) in form.colorRows" :key="index" class="color-variant">
|
||||
<div v-for="(row, index) in form.colorRows" :key="row.id" class="color-variant">
|
||||
<AdminImagePicker
|
||||
:model-value="row.imageUrl || null"
|
||||
@update:model-value="row.imageUrl = $event ?? ''"
|
||||
@@ -594,27 +616,6 @@ function clearFilters() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label>تصاویر عمومی محصول (اختیاری)</label>
|
||||
<p class="color-variants__hint">پس از انتخاب فایل، حتماً دکمه ذخیره را بزنید.</p>
|
||||
<div class="product-images">
|
||||
<div
|
||||
v-for="(src, index) in form.extraImageUrls"
|
||||
:key="`${index}-${src.slice(0, 24)}`"
|
||||
class="product-images__item"
|
||||
>
|
||||
<span class="product-images__thumb" :style="{ backgroundImage: toCssBackgroundUrl(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>
|
||||
@@ -714,44 +715,4 @@ function clearFilters() {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
|
||||
import AdminMultiSelect from '@/components/ui/AdminMultiSelect.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
customersApi,
|
||||
@@ -34,25 +35,23 @@ const broadcast = reactive({ title: '', body: '' })
|
||||
const broadcasting = ref(false)
|
||||
const customers = ref<Customer[]>([])
|
||||
const selectedCustomerIds = ref<string[]>([])
|
||||
const sendToAllActive = ref(false)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const broadcastButtonLabel = computed(() => {
|
||||
if (broadcasting.value) return 'در حال ارسال…'
|
||||
if (selectedCustomerIds.value.length) {
|
||||
return `ارسال به ${selectedCustomerIds.value.length} مشتری انتخابشده`
|
||||
}
|
||||
return 'ارسال به همه مشتریان فعال'
|
||||
})
|
||||
const activeCustomerOptions = computed(() =>
|
||||
customers.value
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} · ${c.phone}${c.appRole === 'agent' ? ' (واسطه)' : ''}`,
|
||||
})),
|
||||
)
|
||||
|
||||
function toggleCustomer(id: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!selectedCustomerIds.value.includes(id)) selectedCustomerIds.value.push(id)
|
||||
} else {
|
||||
selectedCustomerIds.value = selectedCustomerIds.value.filter((cid) => cid !== id)
|
||||
}
|
||||
}
|
||||
watch(sendToAllActive, (all) => {
|
||||
if (all) selectedCustomerIds.value = []
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
@@ -97,21 +96,41 @@ async function saveSettings() {
|
||||
}
|
||||
|
||||
async function sendBroadcast() {
|
||||
if (!broadcast.title.trim() || !broadcast.body.trim() || broadcasting.value) return
|
||||
if (broadcasting.value) return
|
||||
const title = broadcast.title.trim()
|
||||
const body = broadcast.body.trim()
|
||||
if (!title || !body) {
|
||||
toast.error('عنوان و متن اعلان را وارد کنید.')
|
||||
return
|
||||
}
|
||||
if (!sendToAllActive.value && !selectedCustomerIds.value.length) {
|
||||
toast.error('حداقل یک مشتری انتخاب کنید یا «ارسال به همه مشتریان فعال» را فعال کنید.')
|
||||
return
|
||||
}
|
||||
|
||||
broadcasting.value = true
|
||||
try {
|
||||
const payload: { title: string; body: string; customerIds?: string[] } = {
|
||||
title: broadcast.title.trim(),
|
||||
body: broadcast.body.trim(),
|
||||
}
|
||||
if (selectedCustomerIds.value.length) {
|
||||
const payload: {
|
||||
title: string
|
||||
body: string
|
||||
allActive?: boolean
|
||||
customerIds?: string[]
|
||||
} = { title, body }
|
||||
if (sendToAllActive.value) {
|
||||
payload.allActive = true
|
||||
} else {
|
||||
payload.customerIds = [...selectedCustomerIds.value]
|
||||
}
|
||||
const res = await notificationsApi.broadcast(payload)
|
||||
if (!res.sent) {
|
||||
toast.warning('هیچ مشتری فعالی برای ارسال پیدا نشد.')
|
||||
return
|
||||
}
|
||||
toast.success(`اعلان برای ${res.sent} مشتری ارسال شد.`)
|
||||
broadcast.title = ''
|
||||
broadcast.body = ''
|
||||
selectedCustomerIds.value = []
|
||||
sendToAllActive.value = false
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'ارسال اعلان ناموفق بود.')
|
||||
} finally {
|
||||
@@ -262,26 +281,33 @@ async function sendBroadcast() {
|
||||
<label for="broadcast-body">متن</label>
|
||||
<textarea id="broadcast-body" v-model="broadcast.body" class="admin-input" rows="4" />
|
||||
</div>
|
||||
<div v-if="customers.length" class="admin-form__field">
|
||||
<label>گیرندگان (بدون انتخاب = همه)</label>
|
||||
<div class="settings-page__customers">
|
||||
<label v-for="customer in customers" :key="customer.id" class="settings-page__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCustomerIds.includes(customer.id)"
|
||||
@change="toggleCustomer(customer.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ customer.name }} <span class="settings-page__phone" dir="ltr">{{ customer.phone }}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="settings-page__check settings-page__check--block">
|
||||
<input v-model="sendToAllActive" type="checkbox" />
|
||||
<span>ارسال به همه مشتریان فعال (نقش مشتری در اپ)</span>
|
||||
</label>
|
||||
<div class="admin-form__field">
|
||||
<label for="broadcast-recipients">گیرندگان</label>
|
||||
<AdminMultiSelect
|
||||
id="broadcast-recipients"
|
||||
v-model="selectedCustomerIds"
|
||||
:options="activeCustomerOptions"
|
||||
:disabled="sendToAllActive || !activeCustomerOptions.length"
|
||||
block
|
||||
placeholder="انتخاب مشتریان…"
|
||||
aria-label="گیرندگان اعلان"
|
||||
/>
|
||||
<p class="settings-page__hint">
|
||||
با غیرفعال بودن «ارسال به همه»، یک یا چند مشتری فعال را انتخاب کنید. اعلان در بخش
|
||||
اعلانهای اپ مشتری نمایش داده میشود.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline"
|
||||
:disabled="broadcasting || !broadcast.title.trim() || !broadcast.body.trim()"
|
||||
class="admin-btn admin-btn--primary settings-page__broadcast-submit"
|
||||
:disabled="broadcasting"
|
||||
@click="sendBroadcast"
|
||||
>
|
||||
{{ broadcastButtonLabel }}
|
||||
{{ broadcasting ? 'در حال ارسال…' : 'ثبت و ارسال اعلان' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -325,19 +351,12 @@ async function sendBroadcast() {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.settings-page__customers {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
.settings-page__check--block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-page__phone {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
margin-right: 6px;
|
||||
.settings-page__broadcast-submit {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-page__hint {
|
||||
|
||||
Reference in New Issue
Block a user