Initial reforms
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import AdminSelect from '@/components/ui/AdminSelect.vue'
|
||||
import { productsApi, suggestionsApi, type ProductSuggestion } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Product } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const list = ref<ProductSuggestion[]>([])
|
||||
const products = ref<Product[]>([])
|
||||
const selectedProductIds = ref<string[]>([])
|
||||
const note = ref('')
|
||||
|
||||
const productOptions = computed(() =>
|
||||
products.value
|
||||
.filter((p) => !list.value.some((s) => s.productId === p.id && s.isActive))
|
||||
.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.productCode})`,
|
||||
})),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
if (!props.customerId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [suggestions, productRows] = await Promise.all([
|
||||
suggestionsApi.list(props.customerId),
|
||||
productsApi.list(),
|
||||
])
|
||||
list.value = suggestions
|
||||
products.value = productRows
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری پیشنهادها ناموفق بود')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addSelected() {
|
||||
if (!selectedProductIds.value.length) return
|
||||
saving.value = true
|
||||
try {
|
||||
for (const productId of selectedProductIds.value) {
|
||||
await suggestionsApi.create({
|
||||
customerId: props.customerId,
|
||||
productId,
|
||||
note: note.value.trim() || undefined,
|
||||
})
|
||||
}
|
||||
selectedProductIds.value = []
|
||||
note.value = ''
|
||||
toast.success('پیشنهادها به صفحه خانه مشتری اضافه شد')
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ثبت پیشنهاد ناموفق بود')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(row: ProductSuggestion) {
|
||||
try {
|
||||
await suggestionsApi.setActive(row.id, !row.isActive)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بهروزرسانی ناموفق بود')
|
||||
}
|
||||
}
|
||||
|
||||
function onProductPick(productId: string) {
|
||||
if (!productId) return
|
||||
if (!selectedProductIds.value.includes(productId)) {
|
||||
selectedProductIds.value = [...selectedProductIds.value, productId]
|
||||
}
|
||||
}
|
||||
|
||||
function removePick(productId: string) {
|
||||
selectedProductIds.value = selectedProductIds.value.filter((id) => id !== productId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.customerId,
|
||||
() => {
|
||||
void load()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="suggestions-panel">
|
||||
<header class="suggestions-panel__head">
|
||||
<div>
|
||||
<h3 class="suggestions-panel__title">پیشنهاد اختصاصی در اپ</h3>
|
||||
<p class="suggestions-panel__hint">
|
||||
محصولات انتخابشده در بخش «پیشنهاد اختصاصی ما برای شما» در صفحه خانه مشتری نمایش داده میشوند.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="suggestions-panel__loading">در حال بارگذاری...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="suggestions-panel__add">
|
||||
<label class="field">
|
||||
افزودن محصول (کد کالیته)
|
||||
<AdminSelect
|
||||
:model-value="''"
|
||||
:options="[{ value: '', label: 'انتخاب محصول...' }, ...productOptions]"
|
||||
@update:model-value="onProductPick"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="selectedProductIds.length" class="suggestions-panel__picked">
|
||||
<span
|
||||
v-for="id in selectedProductIds"
|
||||
:key="id"
|
||||
class="suggestions-panel__chip"
|
||||
>
|
||||
{{ products.find((p) => p.id === id)?.productCode || id }}
|
||||
<button type="button" aria-label="حذف" @click="removePick(id)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
یادداشت (اختیاری)
|
||||
<textarea v-model="note" class="admin-input" rows="2" placeholder="دلیل پیشنهاد برای مشتری" />
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary admin-btn--sm"
|
||||
:disabled="!selectedProductIds.length || saving"
|
||||
@click="addSelected"
|
||||
>
|
||||
افزودن به صفحه خانه مشتری
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="list.length" class="suggestions-panel__list">
|
||||
<h4 class="suggestions-panel__sub">پیشنهادهای ثبتشده</h4>
|
||||
<ul>
|
||||
<li v-for="row in list" :key="row.id" class="suggestions-panel__row">
|
||||
<div>
|
||||
<strong>{{ row.productName }}</strong>
|
||||
<span class="muted" dir="ltr">({{ row.productCode }})</span>
|
||||
<p v-if="row.note" class="note">{{ row.note }}</p>
|
||||
</div>
|
||||
<div class="suggestions-panel__actions">
|
||||
<span :class="row.isActive ? 'on' : 'off'">{{ row.isActive ? 'فعال' : 'غیرفعال' }}</span>
|
||||
<button type="button" class="link" @click="toggle(row)">
|
||||
{{ row.isActive ? 'حذف از خانه' : 'فعالسازی' }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-else class="suggestions-panel__empty">هنوز پیشنهادی برای این مشتری ثبت نشده است.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suggestions-panel {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.suggestions-panel__head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.suggestions-panel__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.suggestions-panel__hint {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggestions-panel__loading,
|
||||
.suggestions-panel__empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.suggestions-panel__add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.suggestions-panel__picked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestions-panel__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.suggestions-panel__chip button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.suggestions-panel__sub {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.suggestions-panel__list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suggestions-panel__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.suggestions-panel__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.on {
|
||||
color: var(--color-success);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.off {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ 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 CustomerSuggestionsPanel from './CustomerSuggestionsPanel.vue'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
|
||||
@@ -19,6 +20,7 @@ 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)
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
@@ -49,6 +51,15 @@ const appRoleOptions = [
|
||||
{ value: 'agent', label: 'واسطه' },
|
||||
]
|
||||
|
||||
const APP_ROLE_LABELS: Record<'customer' | 'agent', string> = {
|
||||
customer: 'مشتری',
|
||||
agent: 'واسطه',
|
||||
}
|
||||
|
||||
function appRoleLabel(role?: 'customer' | 'agent' | null) {
|
||||
return APP_ROLE_LABELS[role === 'agent' ? 'agent' : 'customer']
|
||||
}
|
||||
|
||||
const levelOptions = computed(() => [
|
||||
{ value: '', label: 'بدون سطح' },
|
||||
...levels.value.map((level) => ({ value: level.id, label: level.label })),
|
||||
@@ -109,14 +120,27 @@ function toggleBrand(brandId: string, checked: boolean) {
|
||||
async function loadCustomers() {
|
||||
loading.value = true
|
||||
try {
|
||||
customerList.value = await customersApi.list()
|
||||
customerList.value = await customersApi.list(undefined, roleTab.value)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری مشتریان انجام نشد.')
|
||||
toast.error(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: roleTab.value === 'agent'
|
||||
? 'بارگذاری واسطهها انجام نشد.'
|
||||
: 'بارگذاری مشتریان انجام نشد.',
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setRoleTab(tab: 'customer' | 'agent') {
|
||||
if (roleTab.value === tab) return
|
||||
roleTab.value = tab
|
||||
searchQuery.value = ''
|
||||
void loadCustomers()
|
||||
}
|
||||
|
||||
async function loadLookups() {
|
||||
try {
|
||||
const [levelsData, agentsData, brandsData] = await Promise.all([
|
||||
@@ -141,6 +165,7 @@ function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
form.appRole = roleTab.value
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
@@ -219,10 +244,33 @@ function clearSearch() {
|
||||
<div class="customers-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">مشتریان</h1>
|
||||
<h1 class="admin-page-header__title">مشتریان و واسطهها</h1>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن مشتری
|
||||
{{ roleTab === 'agent' ? 'افزودن واسطه' : 'افزودن مشتری' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="customers-page__tabs" role="tablist" aria-label="نوع کاربر">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="customers-page__tab"
|
||||
:class="{ 'is-active': roleTab === 'customer' }"
|
||||
:aria-selected="roleTab === 'customer'"
|
||||
@click="setRoleTab('customer')"
|
||||
>
|
||||
مشتریان
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="customers-page__tab"
|
||||
:class="{ 'is-active': roleTab === 'agent' }"
|
||||
:aria-selected="roleTab === 'agent'"
|
||||
@click="setRoleTab('agent')"
|
||||
>
|
||||
واسطهها
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -241,18 +289,26 @@ function clearSearch() {
|
||||
v-else-if="!filteredCustomers.length"
|
||||
class="admin-card"
|
||||
icon="customers"
|
||||
:title="searchQuery.trim() ? 'نتیجهای یافت نشد' : 'مشتریان ثبت شده'"
|
||||
:title="
|
||||
searchQuery.trim()
|
||||
? 'نتیجهای یافت نشد'
|
||||
: roleTab === 'agent'
|
||||
? 'واسطهای ثبت نشده'
|
||||
: 'مشتریان ثبت شده'
|
||||
"
|
||||
:description="
|
||||
searchQuery.trim()
|
||||
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
|
||||
: 'برای شروع، اولین مشتری را ثبت کنید.'
|
||||
: roleTab === 'agent'
|
||||
? 'برای شروع، اولین واسطه را ثبت کنید.'
|
||||
: 'برای شروع، اولین مشتری را ثبت کنید.'
|
||||
"
|
||||
>
|
||||
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
|
||||
پاک کردن جستجو
|
||||
</button>
|
||||
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن مشتری
|
||||
{{ roleTab === 'agent' ? 'افزودن واسطه' : 'افزودن مشتری' }}
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
@@ -261,13 +317,14 @@ function clearSearch() {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام مشتری</th>
|
||||
<th>{{ roleTab === 'agent' ? 'نام واسطه' : 'نام مشتری' }}</th>
|
||||
<th>تلفن</th>
|
||||
<th>شناسه ملی</th>
|
||||
<th>نوع</th>
|
||||
<th>سطح</th>
|
||||
<th>واسطه</th>
|
||||
<th>شرکت</th>
|
||||
<th>نقش</th>
|
||||
<th v-if="roleTab === 'agent'">کد واسطه</th>
|
||||
<th v-if="roleTab === 'customer'">نوع</th>
|
||||
<th v-if="roleTab === 'customer'">سطح</th>
|
||||
<th v-if="roleTab === 'customer'">واسطه</th>
|
||||
<th v-if="roleTab === 'customer'">شرکت</th>
|
||||
<th class="numeric">تعداد سفارش</th>
|
||||
<th class="numeric">مجموع متراژ</th>
|
||||
<th class="status">وضعیت</th>
|
||||
@@ -279,11 +336,12 @@ function clearSearch() {
|
||||
<tr v-for="customer in filteredCustomers" :key="customer.id">
|
||||
<td>{{ displayCell(customer.name) }}</td>
|
||||
<td>{{ displayCell(customer.phone) }}</td>
|
||||
<td>{{ displayCell(customer.nationalId) }}</td>
|
||||
<td>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</td>
|
||||
<td>{{ displayCell(customer.levelLabel) }}</td>
|
||||
<td>{{ displayCell(customer.assignedAgentName) }}</td>
|
||||
<td>{{ displayCell(customer.companyName) }}</td>
|
||||
<td>{{ appRoleLabel(customer.appRole) }}</td>
|
||||
<td v-if="roleTab === 'agent'">{{ displayCell(customer.agentCode) }}</td>
|
||||
<td v-if="roleTab === 'customer'">{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</td>
|
||||
<td v-if="roleTab === 'customer'">{{ displayCell(customer.levelLabel) }}</td>
|
||||
<td v-if="roleTab === 'customer'">{{ displayCell(customer.assignedAgentName) }}</td>
|
||||
<td v-if="roleTab === 'customer'">{{ displayCell(customer.companyName) }}</td>
|
||||
<td class="numeric">{{ displayCell(customer.orderCount) }}</td>
|
||||
<td class="numeric">{{ displayMeterage(customer.totalMeterage) }}</td>
|
||||
<td class="status">
|
||||
@@ -326,10 +384,11 @@ function clearSearch() {
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ displayCell(customer.phone) }}</span>
|
||||
<span>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</span>
|
||||
<span>{{ displayCell(customer.levelLabel) }}</span>
|
||||
<span>{{ displayCell(customer.assignedAgentName) }}</span>
|
||||
<span>{{ displayCell(customer.companyName) }}</span>
|
||||
<span v-if="roleTab === 'agent'">کد: {{ displayCell(customer.agentCode) }}</span>
|
||||
<span v-if="roleTab === 'customer'">{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</span>
|
||||
<span v-if="roleTab === 'customer'">{{ displayCell(customer.levelLabel) }}</span>
|
||||
<span v-if="roleTab === 'customer'">واسطه: {{ displayCell(customer.assignedAgentName) }}</span>
|
||||
<span v-if="roleTab === 'customer'">{{ displayCell(customer.companyName) }}</span>
|
||||
<span>
|
||||
{{ displayCell(customer.orderCount) }} سفارش · {{ displayMeterage(customer.totalMeterage) }}
|
||||
</span>
|
||||
@@ -441,13 +500,24 @@ function clearSearch() {
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
|
||||
<CustomerSuggestionsPanel
|
||||
v-if="modalMode === 'edit' && editingId && form.appRole === 'customer'"
|
||||
:customer-id="editingId"
|
||||
/>
|
||||
</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="submitCustomer">
|
||||
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
|
||||
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت مشتری' }}
|
||||
{{
|
||||
modalMode === 'edit'
|
||||
? 'ذخیره تغییرات'
|
||||
: form.appRole === 'agent'
|
||||
? 'ثبت واسطه'
|
||||
: 'ثبت مشتری'
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
@@ -455,6 +525,30 @@ function clearSearch() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customers-page__tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.customers-page__tab {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.customers-page__tab.is-active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.customers-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user