2026-09-23 01:12:53 +03:30
|
|
|
|
<!--
|
|
|
|
|
|
"لیست مشتریان" — the sales expert's own referred customers.
|
|
|
|
|
|
|
|
|
|
|
|
Ported from legacy henza_pwa/src/views/user/src/components/customerChoice.vue
|
|
|
|
|
|
(route name customerChoice, title "انتخاب مشتری"), restyled into this app's
|
|
|
|
|
|
design language. The LOGIC is the reference's and is identical in
|
|
|
|
|
|
user-app-v2; only the styling differs.
|
|
|
|
|
|
|
|
|
|
|
|
Kept from the reference:
|
|
|
|
|
|
- the header title "لیست مشتریان" (customerChoice.vue:8)
|
|
|
|
|
|
- the search box, re-querying on each search (:11, :184)
|
|
|
|
|
|
- per card: name, mobile, phone, address and the active/inactive state
|
|
|
|
|
|
- inactive customers are LISTED, not hidden — the reference's `active()`
|
|
|
|
|
|
scope is commented out at ApiMobileController.php:187, so every referred
|
|
|
|
|
|
customer appears whatever their state (D4: replicate, don't "fix")
|
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
|
`changeUser` / act-as IS ported — see `enterAccount` below and D29. It was
|
|
|
|
|
|
previously left out as a session-hijack primitive (D21); the owner asked for
|
|
|
|
|
|
it back, so the capability is the reference's but the mechanism is not. The
|
|
|
|
|
|
reference minted a permanent api_token per customer
|
|
|
|
|
|
(ApiMobileController.php:197-201) and swapped the bearer token; here the
|
|
|
|
|
|
expert keeps their own token and the client names the customer in a header,
|
|
|
|
|
|
which the server re-authorises on every request.
|
|
|
|
|
|
|
|
|
|
|
|
Still deliberately NOT ported (D21):
|
|
|
|
|
|
- The activate/deactivate toggle and the edit button, which posted to
|
|
|
|
|
|
`/customer/toggle-active` and the customer form. Those are admin-panel
|
|
|
|
|
|
operations here. The state is shown, read-only.
|
2026-09-23 01:12:53 +03:30
|
|
|
|
|
|
|
|
|
|
Scope is the server's: `referredByAgentId` = this expert, from the token.
|
|
|
|
|
|
-->
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="cch">
|
|
|
|
|
|
<PageBar title="لیست مشتریان" back-to="userPage::hasSubRoute">
|
|
|
|
|
|
<template #below>
|
|
|
|
|
|
<div class="cch__barExtra">
|
|
|
|
|
|
<SearchInput @search="readSearch" place-holder="جستجو" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</PageBar>
|
|
|
|
|
|
|
|
|
|
|
|
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
|
|
|
|
|
|
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
2026-10-06 16:48:36 +03:30
|
|
|
|
<template #pulling><Loading size="18" /></template>
|
|
|
|
|
|
<template #loosing><Loading size="18" /></template>
|
|
|
|
|
|
<template #loading><Loading size="18" /></template>
|
|
|
|
|
|
<template #success><span /></template>
|
2026-09-23 01:12:53 +03:30
|
|
|
|
<container class="cch__body">
|
|
|
|
|
|
<div v-if="rows.length" class="cch__list">
|
|
|
|
|
|
<article
|
|
|
|
|
|
v-for="item in rows"
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
class="cch__card"
|
|
|
|
|
|
@click="openDetail(item)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="cch__head">
|
|
|
|
|
|
<h3 class="cch__name">{{ item.name }}</h3>
|
|
|
|
|
|
<span class="cch__state" :class="item.active === 1 ? 'is-on' : 'is-off'">
|
|
|
|
|
|
{{ item.active === 1 ? 'فعال' : 'غیرفعال' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="cch__facts">
|
|
|
|
|
|
<div class="cch__fact">
|
|
|
|
|
|
<Icon icon="vuesax-linear:mobile" size="15" />
|
|
|
|
|
|
<span dir="ltr">{{ item.mobile || '—' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__fact">
|
|
|
|
|
|
<Icon icon="vuesax-linear:call" size="15" />
|
|
|
|
|
|
<span dir="ltr">{{ item.phone || '—' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="item.store_name" class="cch__fact">
|
|
|
|
|
|
<Icon icon="vuesax-linear:shop" size="15" />
|
|
|
|
|
|
<span>{{ item.store_name }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-else-if="!loading && !failed" class="cch__empty">
|
|
|
|
|
|
<Icon icon="vuesax-linear:profile-2user" size="26" />
|
|
|
|
|
|
<p>{{ search ? 'مشتری با این مشخصات پیدا نشد.' : 'هنوز مشتریای به شما معرفی نشده است.' }}</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-if="failed" class="cch__empty">
|
|
|
|
|
|
<Icon icon="vuesax-linear:danger" size="26" />
|
|
|
|
|
|
<p>دریافت اطلاعات با خطا روبهرو شد.</p>
|
|
|
|
|
|
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</container>
|
|
|
|
|
|
</PullRefresh>
|
|
|
|
|
|
</Spinner>
|
|
|
|
|
|
|
|
|
|
|
|
<Drawer
|
|
|
|
|
|
v-model:open="detailOpen"
|
|
|
|
|
|
placement="bottom"
|
|
|
|
|
|
height="auto"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
:body-style="{ padding: '0' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="active" class="cch__detail">
|
|
|
|
|
|
<h2 class="cch__detailTitle">{{ active.name }}</h2>
|
|
|
|
|
|
<dl class="cch__rows">
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>موبایل</dt>
|
|
|
|
|
|
<dd dir="ltr">{{ active.mobile || '—' }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>تلفن</dt>
|
|
|
|
|
|
<dd dir="ltr">{{ active.phone || '—' }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>نام مغازه</dt>
|
|
|
|
|
|
<dd>{{ active.store_name || '—' }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>نام پاساژ</dt>
|
|
|
|
|
|
<dd>{{ active.pasazh_name || '—' }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>سقف اعتبار</dt>
|
|
|
|
|
|
<dd dir="ltr">{{ grams(active.gram_level) }} گرم</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>بدهی گرمی</dt>
|
|
|
|
|
|
<dd dir="ltr">{{ grams(active.remain) }} گرم</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cch__row">
|
|
|
|
|
|
<dt>وضعیت</dt>
|
|
|
|
|
|
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dl>
|
2026-10-12 11:48:59 +03:30
|
|
|
|
<button type="button" class="cch__enter" :disabled="entering" @click="enterAccount(active)">
|
|
|
|
|
|
{{ entering ? 'در حال ورود…' : 'ورود به حساب این مشتری' }}
|
|
|
|
|
|
</button>
|
2026-09-23 01:12:53 +03:30
|
|
|
|
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { onMounted, ref } from 'vue'
|
2026-10-12 11:48:59 +03:30
|
|
|
|
import { useRouter } from 'vue-router'
|
2026-09-23 01:12:53 +03:30
|
|
|
|
import { Drawer } from 'ant-design-vue'
|
2026-10-06 16:48:36 +03:30
|
|
|
|
import { Loading, PullRefresh } from 'vant'
|
2026-09-23 01:12:53 +03:30
|
|
|
|
import Icon from '../../components/icon/Icon.vue'
|
|
|
|
|
|
import PageBar from '../../components/ui/PageBar.vue'
|
|
|
|
|
|
import Spinner from '../../components/Spinner.vue'
|
|
|
|
|
|
import SearchInput from '../../components/search/SearchInput.vue'
|
|
|
|
|
|
import { api, type ExpertCustomer } from '../../api/client'
|
2026-10-12 11:48:59 +03:30
|
|
|
|
import { useSessionStore } from '../../stores/session'
|
|
|
|
|
|
import { useCartStore } from '../../stores/cartStore'
|
|
|
|
|
|
import { toast } from '../../composables/useToast'
|
|
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const session = useSessionStore()
|
|
|
|
|
|
const cart = useCartStore()
|
|
|
|
|
|
const entering = ref(false)
|
2026-09-23 01:12:53 +03:30
|
|
|
|
|
|
|
|
|
|
const rows = ref<ExpertCustomer[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const refreshing = ref(false)
|
|
|
|
|
|
const failed = ref(false)
|
|
|
|
|
|
const search = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const detailOpen = ref(false)
|
|
|
|
|
|
const active = ref<ExpertCustomer | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
function grams(value: string | number | null | undefined): string {
|
|
|
|
|
|
const n = Number(value ?? 0)
|
|
|
|
|
|
return Number.isFinite(n) ? n.toFixed(3) : '0.000'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The reference returned the whole list in one call (no paging on /my-child,
|
|
|
|
|
|
* ApiMobileController.php:195 ends in `->get()`), so this does the same.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
failed.value = false
|
|
|
|
|
|
try {
|
|
|
|
|
|
rows.value = await api.getExpertCustomers(search.value)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
failed.value = true
|
|
|
|
|
|
rows.value = []
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The reference re-queried the server on each search (customerChoice.vue:184). */
|
|
|
|
|
|
async function readSearch(value: string | undefined) {
|
|
|
|
|
|
search.value = value?.trim() ? value.trim() : null
|
|
|
|
|
|
await load()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onRefresh() {
|
|
|
|
|
|
await load()
|
|
|
|
|
|
refreshing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openDetail(item: ExpertCustomer) {
|
|
|
|
|
|
active.value = item
|
|
|
|
|
|
detailOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
|
/**
|
|
|
|
|
|
* «ورود به حساب مشتری» — the reference's `changeUser`
|
|
|
|
|
|
* (customerChoice.vue:155-167). There it swapped the bearer token for the
|
|
|
|
|
|
* customer's own; here the session simply records who we are acting as and the
|
|
|
|
|
|
* API client names them in a header, which the server authorises per request.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The cart is cleared in both directions: it lives in localStorage, so without
|
|
|
|
|
|
* this the expert's own basket would follow them into the customer's account
|
|
|
|
|
|
* and end up on the customer's order.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function enterAccount(item: ExpertCustomer) {
|
|
|
|
|
|
entering.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
cart.clear()
|
|
|
|
|
|
session.setActingAs({ id: item.id, name: item.name ?? item.fullName ?? item.mobile ?? '' })
|
|
|
|
|
|
detailOpen.value = false
|
|
|
|
|
|
// Same destination as the reference, which returned to the dashboard.
|
|
|
|
|
|
await router.push({ name: 'userPage::hasSubRoute' })
|
|
|
|
|
|
toast.success(`وارد حساب ${item.name ?? ''} شدید`)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
entering.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 01:12:53 +03:30
|
|
|
|
onMounted(load)
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
|
|
|
.cch__barExtra {
|
|
|
|
|
|
padding: 0 16px 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__body {
|
|
|
|
|
|
padding-top: 132px;
|
|
|
|
|
|
padding-bottom: 90px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
padding-top: 16px;
|
|
|
|
|
|
gap: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__card {
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
background: @canvas;
|
|
|
|
|
|
border: 1px solid @hairline;
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: transform 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__card:active {
|
|
|
|
|
|
transform: scale(0.99);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__name {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 15.5px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__state {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 11.5px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__state.is-on {
|
2026-10-04 16:18:56 +03:30
|
|
|
|
color: @green;
|
|
|
|
|
|
background: alpha(@green, 14%);
|
2026-09-23 01:12:53 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__state.is-off {
|
2026-10-04 16:18:56 +03:30
|
|
|
|
color: @red;
|
|
|
|
|
|
background: alpha(@red, 12%);
|
2026-09-23 01:12:53 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__facts {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 7px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__fact {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 7px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: @inkMuted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__empty {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
padding: 34px 20px;
|
|
|
|
|
|
border: 1px dashed @hairline;
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
color: @inkMuted;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__retry {
|
|
|
|
|
|
padding: 8px 18px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
border: 1px solid @hairline;
|
|
|
|
|
|
background: @canvas;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__detail {
|
|
|
|
|
|
padding: 20px 16px 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__detailTitle {
|
|
|
|
|
|
margin: 0 0 16px;
|
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__rows {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 11px 14px;
|
|
|
|
|
|
background: @canvasSoft;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__row dt {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: @inkMuted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cch__row dd {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
|
.cch__enter {
|
2026-09-23 01:12:53 +03:30
|
|
|
|
margin-top: 18px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 13px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 14px;
|
2026-10-04 13:57:25 +03:30
|
|
|
|
background: @primary;
|
2026-09-23 01:12:53 +03:30
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
cursor: pointer;
|
2026-10-12 11:48:59 +03:30
|
|
|
|
|
|
|
|
|
|
&:disabled {
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
cursor: default;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Secondary now that "enter the account" is the primary action here. */
|
|
|
|
|
|
.cch__close {
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 13px;
|
|
|
|
|
|
border: 1px solid fade(@primary, 35%);
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: @primary;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
cursor: pointer;
|
2026-09-23 01:12:53 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|