Initial reforms

This commit is contained in:
2026-09-19 17:23:11 +03:30
parent ab5ce96529
commit 8be9280721
57 changed files with 476 additions and 67 deletions
@@ -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>
+117 -23
View File
@@ -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;