This commit is contained in:
2026-09-30 09:53:30 +03:30
parent 4d27da98fc
commit ebfbeb7a88
64 changed files with 1275 additions and 626 deletions
+2 -2
View File
@@ -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>
+2 -49
View File
@@ -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>
+58 -97
View File
@@ -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>
+66 -47
View File
@@ -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 {