Files
nademitex_admin/src/views/customers/CustomersView.vue
T
2026-09-30 09:53:30 +03:30

546 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>