Files
nademitex_admin/src/views/customers/CustomersView.vue
T

546 lines
18 KiB
Vue
Raw Normal View History

<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'
2026-09-30 09:53:30 +03:30
import { customersApi, levelsApi, type CustomerLevel } from '@/services/api'
2026-09-19 17:23:11 +03:30
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('')
2026-09-19 17:23:11 +03:30
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: 'واسطه' },
]
2026-09-19 17:23:11 +03:30
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 {
2026-09-19 17:23:11 +03:30
customerList.value = await customersApi.list(undefined, roleTab.value)
} catch (err) {
2026-09-19 17:23:11 +03:30
toast.error(
err instanceof ApiError
? err.message
: roleTab.value === 'agent'
? 'بارگذاری واسطه‌ها انجام نشد.'
: 'بارگذاری مشتریان انجام نشد.',
)
} finally {
loading.value = false
}
}
2026-09-19 17:23:11 +03:30
function setRoleTab(tab: 'customer' | 'agent') {
if (roleTab.value === tab) return
roleTab.value = tab
searchQuery.value = ''
void loadCustomers()
}
async function loadLookups() {
try {
2026-09-30 09:53:30 +03:30
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())
2026-09-19 17:23:11 +03:30
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>
2026-09-19 17:23:11 +03:30
<h1 class="admin-page-header__title">مشتریان و واسطه‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
2026-09-19 17:23:11 +03:30
{{ 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"
2026-09-19 17:23:11 +03:30
:title="
searchQuery.trim()
? 'نتیجه‌ای یافت نشد'
: roleTab === 'agent'
? 'واسطه‌ای ثبت نشده'
: 'مشتریان ثبت شده'
"
:description="
searchQuery.trim()
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
2026-09-19 17:23:11 +03:30
: 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">
2026-09-19 17:23:11 +03:30
{{ 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>
2026-09-19 17:23:11 +03:30
<th>{{ roleTab === 'agent' ? 'نام واسطه' : 'نام مشتری' }}</th>
<th>تلفن</th>
2026-09-19 17:23:11 +03:30
<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>
2026-09-19 17:23:11 +03:30
<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>
2026-09-19 17:23:11 +03:30
<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>
2026-09-19 17:23:11 +03:30
<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" />
2026-09-19 17:23:11 +03:30
{{
modalMode === 'edit'
? 'ذخیره تغییرات'
: form.appRole === 'agent'
? 'ثبت واسطه'
: 'ثبت مشتری'
}}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
2026-09-19 17:23:11 +03:30
.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>