546 lines
18 KiB
Vue
546 lines
18 KiB
Vue
<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 { customersApi, levelsApi, 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'
|
||
import { formatDateTime, formatMeterage } from '@/utils/format'
|
||
import type { Customer, CustomerType } from '@/types'
|
||
|
||
const customerList = ref<Customer[]>([])
|
||
const levels = ref<CustomerLevel[]>([])
|
||
const agents = ref<Customer[]>([])
|
||
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)
|
||
const formError = ref('')
|
||
const saving = ref(false)
|
||
const toast = useToast()
|
||
|
||
function displayCell(value: string | number | null | undefined): string {
|
||
if (value == null) return '-'
|
||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '-'
|
||
const text = value.trim()
|
||
if (!text || text === '—') return '-'
|
||
return text
|
||
}
|
||
|
||
function displayMeterage(value: number | null | undefined): string {
|
||
if (value == null || !Number.isFinite(value)) return '-'
|
||
return formatMeterage(value)
|
||
}
|
||
|
||
const customerTypeOptions = [
|
||
{ value: 'wholesale', label: 'عمدهفروش' },
|
||
{ value: 'retail', label: 'خردهفروش' },
|
||
]
|
||
|
||
const appRoleOptions = [
|
||
{ value: 'customer', label: 'مشتری' },
|
||
{ 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 })),
|
||
])
|
||
|
||
const agentOptions = computed(() => [
|
||
{ value: '', label: 'بدون واسطه' },
|
||
...agents.value
|
||
.filter((agent) => agent.id !== editingId.value)
|
||
.map((agent) => ({
|
||
value: agent.id,
|
||
label: agent.name + (agent.agentCode ? ` (${agent.agentCode})` : ''),
|
||
})),
|
||
])
|
||
|
||
const emptyForm = () => ({
|
||
name: '',
|
||
phone: '',
|
||
nationalId: '',
|
||
customerType: 'wholesale' as CustomerType,
|
||
companyName: '',
|
||
isActive: true,
|
||
levelDefId: '' as string,
|
||
assignedAgentId: '' as string,
|
||
appRole: 'customer' as 'customer' | 'agent',
|
||
agentCode: '',
|
||
})
|
||
|
||
const form = reactive(emptyForm())
|
||
|
||
const isModalOpen = computed(() => modalMode.value !== null)
|
||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش مشتری' : 'افزودن مشتری'))
|
||
const isFormValid = computed(
|
||
() => form.name.trim() && form.phone.trim() && form.nationalId.trim(),
|
||
)
|
||
|
||
const filteredCustomers = computed(() => {
|
||
const q = searchQuery.value.trim()
|
||
if (!q) return customerList.value
|
||
return customerList.value.filter(
|
||
(c) =>
|
||
c.name.includes(q) ||
|
||
c.phone.includes(q) ||
|
||
c.nationalId.includes(q) ||
|
||
c.companyName.includes(q),
|
||
)
|
||
})
|
||
|
||
async function loadCustomers() {
|
||
loading.value = true
|
||
try {
|
||
customerList.value = await customersApi.list(undefined, roleTab.value)
|
||
} catch (err) {
|
||
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] = await Promise.all([
|
||
levelsApi.list(),
|
||
customersApi.list(undefined, 'agent'),
|
||
])
|
||
levels.value = levelsData
|
||
agents.value = agentsData
|
||
} catch (err) {
|
||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری اطلاعات کمکی انجام نشد.')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadCustomers()
|
||
void loadLookups()
|
||
})
|
||
|
||
function openAddModal() {
|
||
modalMode.value = 'add'
|
||
editingId.value = null
|
||
Object.assign(form, emptyForm())
|
||
form.appRole = roleTab.value
|
||
formError.value = ''
|
||
}
|
||
|
||
function openEditModal(customer: Customer) {
|
||
modalMode.value = 'edit'
|
||
editingId.value = customer.id
|
||
form.name = customer.name
|
||
form.phone = customer.phone
|
||
form.nationalId = customer.nationalId
|
||
form.customerType = customer.customerType
|
||
form.companyName = customer.companyName
|
||
form.isActive = customer.isActive
|
||
form.levelDefId = customer.levelDefId ?? ''
|
||
form.assignedAgentId = customer.assignedAgentId ?? ''
|
||
form.appRole = customer.appRole === 'agent' ? 'agent' : 'customer'
|
||
form.agentCode = customer.agentCode ?? ''
|
||
formError.value = ''
|
||
}
|
||
|
||
function closeModal() {
|
||
modalMode.value = null
|
||
editingId.value = null
|
||
formError.value = ''
|
||
}
|
||
|
||
async function submitCustomer() {
|
||
if (!isFormValid.value) {
|
||
formError.value = 'نام، تلفن و شناسه ملی الزامی هستند.'
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
const payload = {
|
||
name: form.name.trim(),
|
||
phone: form.phone.trim(),
|
||
nationalId: form.nationalId.trim(),
|
||
customerType: form.customerType,
|
||
isActive: form.isActive,
|
||
levelDefId: form.levelDefId || null,
|
||
assignedAgentId: form.assignedAgentId || null,
|
||
appRole: form.appRole,
|
||
agentCode: form.appRole === 'agent' ? form.agentCode.trim() || null : null,
|
||
}
|
||
|
||
try {
|
||
if (modalMode.value === 'edit' && editingId.value) {
|
||
const updated = await customersApi.update(editingId.value, payload)
|
||
const index = customerList.value.findIndex((c) => c.id === editingId.value)
|
||
if (index !== -1) customerList.value[index] = { ...customerList.value[index]!, ...updated }
|
||
} else {
|
||
const created = await customersApi.create(payload)
|
||
customerList.value.unshift(created)
|
||
}
|
||
toast.success(modalMode.value === 'edit' ? 'اطلاعات مشتری بهروزرسانی شد.' : 'مشتری جدید ثبت شد.')
|
||
closeModal()
|
||
if (payload.appRole === 'agent') void loadLookups()
|
||
} catch (err) {
|
||
toast.error(err instanceof ApiError ? err.message : 'ذخیره مشتری انجام نشد.')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function persistActive(id: string, isActive: boolean) {
|
||
await customersApi.setActive(id, isActive)
|
||
}
|
||
|
||
function clearSearch() {
|
||
searchQuery.value = ''
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="customers-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">
|
||
{{ 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>
|
||
|
||
<div class="customers-page__search admin-card">
|
||
<AdminIcon name="search" :size="18" />
|
||
<input
|
||
v-model="searchQuery"
|
||
type="search"
|
||
class="customers-page__search-input"
|
||
placeholder="جستجو در نام، تلفن، شناسه ملی..."
|
||
/>
|
||
</div>
|
||
|
||
<AdminPageSkeleton v-if="loading" variant="table" />
|
||
<BaseEmptyState
|
||
v-else-if="!filteredCustomers.length"
|
||
class="admin-card"
|
||
icon="customers"
|
||
: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>
|
||
|
||
<div v-else class="customers-page__table admin-card desktop-only">
|
||
<div class="admin-table-wrap">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ roleTab === 'agent' ? 'نام واسطه' : 'نام مشتری' }}</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>
|
||
<th>تاریخ ثبت</th>
|
||
<th class="actions">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="customer in filteredCustomers" :key="customer.id">
|
||
<td>{{ displayCell(customer.name) }}</td>
|
||
<td>{{ displayCell(customer.phone) }}</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">
|
||
<ActiveSwitchCell
|
||
:id="customer.id"
|
||
v-model="customer.isActive"
|
||
:entity-name="customer.name"
|
||
entity-type="مشتری"
|
||
:persist="persistActive"
|
||
/>
|
||
</td>
|
||
<td>{{ displayCell(formatDateTime(customer.registeredAt)) }}</td>
|
||
<td class="actions">
|
||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
|
||
ویرایش
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!loading && filteredCustomers.length" class="compact-only admin-card-list">
|
||
<article v-for="customer in filteredCustomers" :key="customer.id" class="admin-card-item">
|
||
<div class="admin-card-item__header">
|
||
<p class="admin-card-item__title">{{ displayCell(customer.name) }}</p>
|
||
<div class="admin-card-item__header-actions">
|
||
<ActiveSwitchCell
|
||
:id="customer.id"
|
||
v-model="customer.isActive"
|
||
:entity-name="customer.name"
|
||
entity-type="مشتری"
|
||
:persist="persistActive"
|
||
/>
|
||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
|
||
ویرایش
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="admin-card-item__meta">
|
||
<span>{{ displayCell(customer.phone) }}</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>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||
<form class="admin-form" @submit.prevent="submitCustomer">
|
||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="customer-name">نام مشتری <span class="admin-form__required">*</span></label>
|
||
<input id="customer-name" v-model="form.name" type="text" class="admin-input" />
|
||
</div>
|
||
|
||
<div class="admin-form__row">
|
||
<div class="admin-form__field">
|
||
<label for="customer-phone">تلفن <span class="admin-form__required">*</span></label>
|
||
<input
|
||
id="customer-phone"
|
||
v-model="form.phone"
|
||
type="tel"
|
||
class="admin-input"
|
||
inputmode="tel"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
<div class="admin-form__field">
|
||
<label for="customer-national-id">شناسه ملی <span class="admin-form__required">*</span></label>
|
||
<input
|
||
id="customer-national-id"
|
||
v-model="form.nationalId"
|
||
type="text"
|
||
class="admin-input"
|
||
inputmode="numeric"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-form__row">
|
||
<div class="admin-form__field">
|
||
<label>نوع مشتری</label>
|
||
<AdminSelect
|
||
v-model="form.customerType"
|
||
block
|
||
aria-label="نوع مشتری"
|
||
:options="customerTypeOptions"
|
||
/>
|
||
</div>
|
||
<div class="admin-form__field">
|
||
<label for="customer-company">شرکت مرتبط</label>
|
||
<input id="customer-company" v-model="form.companyName" type="text" class="admin-input" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-form__row">
|
||
<div class="admin-form__field">
|
||
<label>سطح</label>
|
||
<AdminSelect
|
||
v-model="form.levelDefId"
|
||
block
|
||
aria-label="سطح مشتری"
|
||
:options="levelOptions"
|
||
/>
|
||
</div>
|
||
<div class="admin-form__field">
|
||
<label>واسطه</label>
|
||
<AdminSelect
|
||
v-model="form.assignedAgentId"
|
||
block
|
||
aria-label="واسطه اختصاصیافته"
|
||
:options="agentOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-form__row">
|
||
<div class="admin-form__field">
|
||
<label>نقش در اپ</label>
|
||
<AdminSelect
|
||
v-model="form.appRole"
|
||
block
|
||
aria-label="نقش در اپ"
|
||
:options="appRoleOptions"
|
||
/>
|
||
</div>
|
||
<div v-if="form.appRole === 'agent'" class="admin-form__field">
|
||
<label for="customer-agent-code">کد واسطه</label>
|
||
<input id="customer-agent-code" v-model="form.agentCode" type="text" class="admin-input" dir="ltr" />
|
||
</div>
|
||
</div>
|
||
|
||
<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'
|
||
? 'ذخیره تغییرات'
|
||
: form.appRole === 'agent'
|
||
? 'ثبت واسطه'
|
||
: 'ثبت مشتری'
|
||
}}
|
||
</button>
|
||
</template>
|
||
</BaseModal>
|
||
</div>
|
||
</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;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 16px;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.customers-page__search-input {
|
||
flex: 1;
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 0.875rem;
|
||
outline: none;
|
||
}
|
||
|
||
.customers-page__table {
|
||
overflow: hidden;
|
||
}
|
||
|
||
</style>
|