Files
hedena_gold/apps/user-app/src/views/expert/CustomerChoiceView.vue
T
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30

351 lines
9.4 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.
<!--
"لیست مشتریان" — 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")
Deliberately NOT ported — two things, both recorded in D21:
1. `changeUser` / act-as. The reference minted an api_token for each listed
customer (ApiMobileController.php:197-201) so the agent could act AS them.
That is a session-hijack primitive; every session here derives from the
caller's own token, so tapping a card opens their details instead.
2. The activate/deactivate toggle and the edit button, which posted to
`/customer/toggle-active` and the customer form. Those are admin-panel
operations here, and adding a write path to the app during a delivery is
not worth the risk. The state is shown, read-only.
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">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<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>
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
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'
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
}
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 {
color: @green;
background: alpha(@green, 14%);
}
.cch__state.is-off {
color: @red;
background: alpha(@red, 12%);
}
.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;
}
.cch__close {
margin-top: 18px;
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>