feat(apps): sales-expert screens in both apps + v2 admin home slider (D21, APP-02)
Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:
ChildOrdersView "سفارش مشتریان" — legacy ChildOrders.vue. Keeps the
reference's title, search, infinite paging, and per-card item count, total
weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
only returns that status, ApiMobileController:766).
CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
title, per-search re-query and the reference's choice to LIST inactive
customers (its active() scope is commented out at :187).
Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.
Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.
APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.
Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.
[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
69f4ba2b29
commit
f71fd5bfe1
@@ -167,6 +167,52 @@ export interface GoldPriceQuote {
|
||||
quotedAt: string;
|
||||
}
|
||||
|
||||
/** One admin-uploaded home banner slide — raw shape as saved by the admin
|
||||
* panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */
|
||||
export interface AdminSlide {
|
||||
image: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* D21 — a sales expert signed into the app. Ordinary customers get
|
||||
* `isSalesExpert: false` and neither expert screen is shown to them.
|
||||
*/
|
||||
export interface ExpertIdentity {
|
||||
isSalesExpert: boolean;
|
||||
type: 'customer' | 'sales-expert';
|
||||
}
|
||||
|
||||
/** One of the expert's referred customers ("انتخاب مشتری"). */
|
||||
export interface ExpertCustomer {
|
||||
id: string;
|
||||
/** Legacy key the reference's card bound to. */
|
||||
name: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
phone: string | null;
|
||||
store_name: string | null;
|
||||
pasazh_name: string | null;
|
||||
gram_level: string;
|
||||
remain: string;
|
||||
rial_remain: string;
|
||||
active: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** One row of "سفارش مشتریان". */
|
||||
export interface ExpertChildOrder {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
/** Total order weight — the reference's `item.w` (ChildOrders.vue:55). */
|
||||
w: string;
|
||||
totalGoldWeightGrams: string;
|
||||
createdAt: string;
|
||||
items: OrderItemDetail[];
|
||||
customer: { id: string; name: string; mobile: string } | null;
|
||||
}
|
||||
export const api = {
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
/**
|
||||
@@ -199,4 +245,23 @@ export const api = {
|
||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
||||
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
||||
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
||||
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
|
||||
* configured any slides (the caller falls back to a derived hero). */
|
||||
getHomeSlider: () => request<AdminSlide[]>('/home/slider'),
|
||||
|
||||
// ── D21: sales-expert screens ────────────────────────────────────────
|
||||
// All three scope themselves on the AdminUser id inside the token; no id
|
||||
// ever travels from the client. An ordinary customer gets 403 on the first
|
||||
// two, and `isSalesExpert: false` from whoami so the menu entries stay hidden.
|
||||
getExpertIdentity: () => request<ExpertIdentity>('/expert/whoami'),
|
||||
getExpertCustomers: (search?: string | null) =>
|
||||
request<ExpertCustomer[]>('/expert/my-customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ search: search ?? null }),
|
||||
}),
|
||||
getExpertChildOrders: (page = 1, search?: string | null) =>
|
||||
request<{ rows: ExpertChildOrder[]; total: number; page: number; limit: number }>(
|
||||
'/expert/child-orders',
|
||||
{ method: 'POST', body: JSON.stringify({ page, search: search ?? null }) },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface HeroSlide {
|
||||
title: string
|
||||
image?: string
|
||||
categoryId?: string
|
||||
/** Admin-uploaded slides carry their own destination instead of a category. */
|
||||
link?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{ slides: HeroSlide[]; autoplay?: boolean }>(), {
|
||||
|
||||
@@ -36,6 +36,22 @@ export const router = createRouter({
|
||||
},
|
||||
{ path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } },
|
||||
// D21 — the sales expert's two screens. Reference paths and names kept
|
||||
// (henza_pwa router/index.ts:171-188). requiresExpert is enforced by the
|
||||
// guard below AND by the server, which scopes both endpoints on the
|
||||
// AdminUser id in the token and answers 403 for anyone else.
|
||||
{
|
||||
path: '/ChildOrder',
|
||||
name: 'ChildOrder',
|
||||
component: () => import('../views/expert/ChildOrdersView.vue'),
|
||||
meta: { title: 'سفارش مشتریان', layout: 'FullPage', requiresAuth: true, requiresExpert: true },
|
||||
},
|
||||
{
|
||||
path: '/customerChoice',
|
||||
name: 'customerChoice',
|
||||
component: () => import('../views/expert/CustomerChoiceView.vue'),
|
||||
meta: { title: 'انتخاب مشتری', layout: 'FullPage', requiresAuth: true, requiresExpert: true },
|
||||
},
|
||||
{ path: '/notifications', name: 'notifications', component: () => import('../views/user/NotificationsView.vue') },
|
||||
{ path: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') },
|
||||
{ path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') },
|
||||
@@ -60,6 +76,13 @@ router.beforeEach((to) => {
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
// D21 — an ordinary customer typing the URL is sent back to their
|
||||
// dashboard. This is convenience, not security: the server refuses both
|
||||
// expert endpoints for a non-expert token regardless of what the app does.
|
||||
if (to.meta.requiresExpert && !session.isSalesExpert) {
|
||||
return { name: 'userPage::hasSubRoute' };
|
||||
}
|
||||
|
||||
// legacy router/index.ts:268-270 — the product page slides down into view.
|
||||
if (to.name === 'singleProduct') nav.setPgaTransition('slideDown');
|
||||
else nav.setPgaTransition(undefined);
|
||||
|
||||
@@ -5,6 +5,8 @@ const CUSTOMER_ID_KEY = 'gold-bank:customerId';
|
||||
const MOBILE_KEY = 'gold-bank:mobile';
|
||||
/** Legacy's isUserLoggedIn() (Mixins/checkAccess.ts) reads this key. */
|
||||
const LEGACY_FLAG_KEY = 'appToken';
|
||||
/** D21 — cached so the expert menu entries render before whoami resolves. */
|
||||
const EXPERT_KEY = 'gold-bank:isSalesExpert';
|
||||
|
||||
/**
|
||||
* The logged-in customer's session.
|
||||
@@ -25,6 +27,15 @@ export const useSessionStore = defineStore('session', {
|
||||
token: localStorage.getItem(TOKEN_KEY) as string | null,
|
||||
customerId: localStorage.getItem(CUSTOMER_ID_KEY) as string | null,
|
||||
mobile: localStorage.getItem(MOBILE_KEY) as string | null,
|
||||
/**
|
||||
* D21 — whether this session belongs to a sales expert.
|
||||
*
|
||||
* A CONVENIENCE ONLY: it decides whether the two expert entries are
|
||||
* rendered. It is never an authorisation claim — the server scopes both
|
||||
* expert routes on the AdminUser id inside the token and answers 403 for
|
||||
* an ordinary customer, so flipping this in devtools reveals nothing.
|
||||
*/
|
||||
isSalesExpert: localStorage.getItem(EXPERT_KEY) === '1',
|
||||
}),
|
||||
getters: {
|
||||
/** Authenticated means holding a token, not merely knowing an id. */
|
||||
@@ -40,6 +51,13 @@ export const useSessionStore = defineStore('session', {
|
||||
localStorage.setItem(MOBILE_KEY, mobile);
|
||||
localStorage.setItem(LEGACY_FLAG_KEY, token);
|
||||
},
|
||||
|
||||
/** Set from GET /expert/whoami right after login. */
|
||||
setSalesExpert(value: boolean) {
|
||||
this.isSalesExpert = value;
|
||||
if (value) localStorage.setItem(EXPERT_KEY, '1');
|
||||
else localStorage.removeItem(EXPERT_KEY);
|
||||
},
|
||||
/**
|
||||
* Clears everything. Called on explicit logout and by the API client when a
|
||||
* request comes back 401 — an expired or revoked token must not leave stale
|
||||
@@ -49,6 +67,8 @@ export const useSessionStore = defineStore('session', {
|
||||
this.token = null;
|
||||
this.customerId = null;
|
||||
this.mobile = null;
|
||||
this.isSalesExpert = false;
|
||||
localStorage.removeItem(EXPERT_KEY);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(CUSTOMER_ID_KEY);
|
||||
localStorage.removeItem(MOBILE_KEY);
|
||||
|
||||
@@ -107,7 +107,7 @@ import { useConfigStore } from '../stores/configStore'
|
||||
import type { HomeCategory } from '../stores/configStore'
|
||||
import { useFiltersStore } from '../stores/filtersStore'
|
||||
import { api } from '../api/client'
|
||||
import type { GoldPriceQuote, ProductSummary } from '../api/client'
|
||||
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
|
||||
|
||||
// Module scope, so it survives this view being unmounted and remounted as the
|
||||
// user moves between tabs, and resets only on a full app reload.
|
||||
@@ -121,6 +121,12 @@ const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const products = ref<ProductSummary[]>([])
|
||||
const goldPrice = ref<GoldPriceQuote | null>(null)
|
||||
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
|
||||
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
|
||||
// knows whether to wait or fall back to the category-derived hero.
|
||||
// Kept in step with user-app, which has had this since the admin screen was
|
||||
// wired up; without it, slides configured in the admin panel never showed here.
|
||||
const adminSlides = ref<AdminSlide[] | null>(null)
|
||||
|
||||
const productDrawer = ref(false)
|
||||
const activeProductId = ref<string | null>(null)
|
||||
@@ -156,14 +162,27 @@ const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
|
||||
// The hero is built from REAL categories, not a curated banner payload (none
|
||||
// exists). Each slide deep-links into search filtered by that category, so the
|
||||
// artwork is decorative but the destination is genuine.
|
||||
const slides = computed<HeroSlide[]>(() =>
|
||||
memory.cats.slice(0, 5).map((cat) => ({
|
||||
const slides = computed<HeroSlide[]>(() => {
|
||||
// Prefer what the admin actually configured; the category-derived hero below
|
||||
// stays as the fallback for when no slide has been uploaded.
|
||||
if (adminSlides.value?.length) {
|
||||
return adminSlides.value
|
||||
.filter((s) => s.image)
|
||||
.map((s, i) => ({
|
||||
key: `${i}-${s.image}`,
|
||||
title: '',
|
||||
image: s.image,
|
||||
link: s.link || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
return memory.cats.slice(0, 5).map((cat) => ({
|
||||
key: cat.id,
|
||||
title: cat.title,
|
||||
image: productImageForCategory(cat.id),
|
||||
categoryId: cat.id,
|
||||
})),
|
||||
)
|
||||
}))
|
||||
})
|
||||
|
||||
function productImageForCategory(categoryId: string): string | undefined {
|
||||
const match = products.value.find((p) => p.category?.id === categoryId)
|
||||
@@ -181,6 +200,17 @@ function onCategory(cat: HomeCategory) {
|
||||
}
|
||||
|
||||
function onSlide(slide: HeroSlide) {
|
||||
// Admin-uploaded slides (manage/Const.vue) carry their own destination
|
||||
// instead of a categoryId — an absolute URL opens in a new tab, anything
|
||||
// else is treated as an in-app path. Same rule as user-app.
|
||||
if (slide.link) {
|
||||
if (/^https?:\/\//i.test(slide.link)) {
|
||||
window.open(slide.link, '_blank', 'noopener')
|
||||
} else {
|
||||
router.push(slide.link)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (slide.categoryId) {
|
||||
filters.query.cats = [slide.categoryId]
|
||||
}
|
||||
@@ -204,6 +234,14 @@ async function loadData() {
|
||||
} catch {
|
||||
goldPrice.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
adminSlides.value = await api.getHomeSlider()
|
||||
} catch {
|
||||
// No admin slides configured, or the endpoint failed — the category-
|
||||
// derived hero covers this silently, same as before this existed.
|
||||
adminSlides.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<!--
|
||||
"سفارش مشتریان" — the sales expert's referred customers' orders.
|
||||
|
||||
Ported from legacy henza_pwa/src/views/user/src/components/ChildOrders.vue,
|
||||
restyled into this app's design language (PageBar + SearchInput + cards, as
|
||||
OrdersView uses). The LOGIC is the reference's and is identical in
|
||||
user-app-v2; only the styling differs between the two apps.
|
||||
|
||||
Kept from the reference:
|
||||
- the title "سفارشات مشتریان" (ChildOrders.vue:18)
|
||||
- the search box over the list (:26)
|
||||
- infinite paging: one page per scroll to the bottom (:327-338)
|
||||
- per card: "سفارش : ORD-{id}", the item count, the total weight in grams,
|
||||
the customer name, and the fixed status chip «تحویل مسئول فروش» — the
|
||||
reference hardcodes that label (:48) because the endpoint only ever
|
||||
returns orders at that status (`where('sts', 5)`, ApiMobileController:766)
|
||||
- the card is tappable, as the reference's was (:29)
|
||||
|
||||
Deliberately NOT ported: the reference's edit-order drawer behind that tap
|
||||
(toEditOrder -> PUT /order/child/{id}) recalculated an order's weights and
|
||||
prices from values the CLIENT sent. Bringing that across during a delivery
|
||||
would put a money path in the app with no server-side recomputation; the tap
|
||||
opens a read-only summary instead. Recorded in D21/DELIVERY.md.
|
||||
|
||||
Scope is the server's: every row belongs to a customer this expert referred.
|
||||
No id travels from the client.
|
||||
-->
|
||||
<template>
|
||||
<div class="cho">
|
||||
<PageBar title="سفارشات مشتریان" back-to="userPage::hasSubRoute">
|
||||
<template #below>
|
||||
<div class="cho__barExtra">
|
||||
<SearchInput @search="readSearch" place-holder="جستجو" />
|
||||
</div>
|
||||
</template>
|
||||
</PageBar>
|
||||
|
||||
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="cho__body">
|
||||
<template v-if="rows.length">
|
||||
<div class="cho__list">
|
||||
<article
|
||||
v-for="item in rows"
|
||||
:key="item.id"
|
||||
class="cho__card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="cho__top">
|
||||
<div class="cho__left">
|
||||
<div class="cho__num">
|
||||
<span class="cho__muted">سفارش :</span>
|
||||
{{ item.orderNumber }}
|
||||
</div>
|
||||
<div class="cho__meta">
|
||||
<span class="cho__strong">{{ item.items?.length ?? 0 }}</span>
|
||||
<span class="cho__unit">آیتم</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cho__right">
|
||||
<span class="cho__status">تحویل مسئول فروش</span>
|
||||
<div class="cho__meta">
|
||||
<span class="cho__strong">{{ grams(item.w) }}</span>
|
||||
<span class="cho__unit">گرم</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cho__divider" />
|
||||
|
||||
<div class="cho__bottom">
|
||||
<div class="cho__customer">
|
||||
نام مشتری : {{ item.customer?.name ?? '—' }}
|
||||
</div>
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="20" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="cho__more">در حال دریافت…</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="!loading" class="cho__empty">
|
||||
<Icon icon="vuesax-linear:box" size="26" />
|
||||
<p>{{ search ? 'سفارشی با این مشخصات پیدا نشد.' : 'هنوز سفارشی ثبت نشده است.' }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="failed" class="cho__empty">
|
||||
<Icon icon="vuesax-linear:danger" size="26" />
|
||||
<p>دریافت اطلاعات با خطا روبهرو شد.</p>
|
||||
<button type="button" class="cho__retry" @click="reload">تلاش دوباره</button>
|
||||
</section>
|
||||
|
||||
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
|
||||
<div ref="sentinel" class="cho__sentinel" />
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
v-model:open="detailOpen"
|
||||
placement="bottom"
|
||||
height="auto"
|
||||
:closable="false"
|
||||
:body-style="{ padding: '0' }"
|
||||
>
|
||||
<div v-if="active" class="cho__detail">
|
||||
<h2 class="cho__detailTitle">{{ active.orderNumber }}</h2>
|
||||
<dl class="cho__rows">
|
||||
<div class="cho__row">
|
||||
<dt>نام مشتری</dt>
|
||||
<dd>{{ active.customer?.name ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="cho__row">
|
||||
<dt>شماره تماس</dt>
|
||||
<dd dir="ltr">{{ active.customer?.mobile ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="cho__row">
|
||||
<dt>وزن کل</dt>
|
||||
<dd dir="ltr">{{ grams(active.w) }} گرم</dd>
|
||||
</div>
|
||||
<div class="cho__row">
|
||||
<dt>تعداد آیتمها</dt>
|
||||
<dd>{{ active.items?.length ?? 0 }}</dd>
|
||||
</div>
|
||||
<div class="cho__row">
|
||||
<dt>تاریخ ثبت</dt>
|
||||
<dd>{{ jdate(active.createdAt) }}</dd>
|
||||
</div>
|
||||
<div class="cho__row">
|
||||
<dt>وضعیت</dt>
|
||||
<dd>تحویل مسئول فروش</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button type="button" class="cho__close" @click="detailOpen = false">بستن</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { 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 { jdate } from '../../utils/filters'
|
||||
import { api, type ExpertChildOrder } from '../../api/client'
|
||||
|
||||
const rows = ref<ExpertChildOrder[]>([])
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const failed = ref(false)
|
||||
const search = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
/** Cleared when a page comes back short, so scrolling stops asking. */
|
||||
const hasMore = ref(true)
|
||||
|
||||
const detailOpen = ref(false)
|
||||
const active = ref<ExpertChildOrder | null>(null)
|
||||
|
||||
/** Weights arrive as decimal strings; shown to 3 places, as the reference did. */
|
||||
function grams(value: string | number | null | undefined): string {
|
||||
const n = Number(value ?? 0)
|
||||
return Number.isFinite(n) ? n.toFixed(3) : '0.000'
|
||||
}
|
||||
|
||||
async function fetchPage() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const data = await api.getExpertChildOrders(page.value, search.value)
|
||||
const incoming = data.rows ?? []
|
||||
rows.value = [...rows.value, ...incoming]
|
||||
if (incoming.length < (data.limit ?? 20)) hasMore.value = false
|
||||
else page.value += 1
|
||||
} catch {
|
||||
failed.value = true
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
rows.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
failed.value = false
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
reset()
|
||||
await fetchPage()
|
||||
}
|
||||
|
||||
/** The reference re-ran the query on every search (ChildOrders.vue:327). */
|
||||
async function readSearch(value: string | undefined) {
|
||||
search.value = value?.trim() ? value.trim() : null
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
await reload()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function openDetail(item: ExpertChildOrder) {
|
||||
active.value = item
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
// Infinite scroll, the reference's behaviour (useInfiniteScroll at :216/:330).
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchPage()
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void fetchPage()
|
||||
})
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/*
|
||||
v2 styling. The template and script above are IDENTICAL to user-app's — only
|
||||
this block differs, which is the rule for the two apps: same logic, each its
|
||||
own design. v2 draws a continuous list separated by hairlines rather than
|
||||
outlined cards, matching its CartView and OrdersView.
|
||||
*/
|
||||
.cho__barExtra {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cho__body {
|
||||
padding-top: 132px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.cho__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.cho__card {
|
||||
padding: 18px 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid @hairline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cho__card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cho__card:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.cho__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cho__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cho__num {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cho__muted {
|
||||
color: @inkFaint;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.cho__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cho__strong {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cho__unit {
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.cho__status {
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #06b6d4;
|
||||
background: #ecfeff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The row separator does this job in v2, so the in-card rule is not drawn. */
|
||||
.cho__divider {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.cho__bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cho__customer {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.cho__more,
|
||||
.cho__sentinel {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.cho__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: @inkFaint;
|
||||
}
|
||||
|
||||
.cho__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.cho__retry {
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid @hairline;
|
||||
background: @canvas;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cho__detail {
|
||||
padding: 20px 16px 24px;
|
||||
}
|
||||
|
||||
.cho__detailTitle {
|
||||
margin: 0 0 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cho__rows {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cho__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 13px 2px;
|
||||
border-bottom: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.cho__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cho__row dt {
|
||||
font-size: 13px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.cho__row dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cho__close {
|
||||
margin-top: 18px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
<!--
|
||||
"لیست مشتریان" — 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">
|
||||
<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 { 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">
|
||||
/*
|
||||
v2 styling. The template and script above are IDENTICAL to user-app's — only
|
||||
this block differs. v2 draws a continuous hairline-separated list rather than
|
||||
outlined cards, matching its CartView and OrdersView.
|
||||
*/
|
||||
.cch__barExtra {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cch__body {
|
||||
padding-top: 132px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.cch__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.cch__card {
|
||||
padding: 18px 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid @hairline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cch__card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cch__card:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.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: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.cch__state.is-off {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.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: @inkFaint;
|
||||
}
|
||||
|
||||
.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: @inkFaint;
|
||||
}
|
||||
|
||||
.cch__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.cch__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 13px 2px;
|
||||
border-bottom: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.cch__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cch__row dt {
|
||||
font-size: 13px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.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: @ink;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -151,6 +151,37 @@
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
|
||||
<!--
|
||||
D21 — the two sales-expert entries, rendered only for an expert, in
|
||||
v2's own row style. The reference showed them the same way, gated on
|
||||
the agent's own account (Dashboard.vue:117-125). An ordinary customer
|
||||
never sees them, and the server refuses both routes for a non-expert
|
||||
token regardless of what the app renders.
|
||||
-->
|
||||
<template v-if="session.isSalesExpert">
|
||||
<button type="button" class="dash__row" @click="goToDetails(4)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:box-tick" size="18" />
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>سفارش مشتریان</b>
|
||||
<small>سفارشهای مشتریان معرفیشده شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(5)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:profile-2user" size="18" />
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>مشتریان من</b>
|
||||
<small>فهرست مشتریان معرفیشده شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Live 18k rate — a single inline row, not a titled section. -->
|
||||
@@ -227,6 +258,8 @@ function goToDetails(id: number) {
|
||||
if (id === 1) router.push({ name: 'transactionsList' })
|
||||
if (id === 2) router.push({ name: 'profileData' })
|
||||
if (id === 3) router.push({ name: 'favoritesList' })
|
||||
if (id === 4) router.push({ name: 'ChildOrder' })
|
||||
if (id === 5) router.push({ name: 'customerChoice' })
|
||||
}
|
||||
|
||||
function logOut() {
|
||||
|
||||
@@ -165,6 +165,16 @@ const sendToken = async (otpCode: string) => {
|
||||
body.loading = true
|
||||
const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
|
||||
session.login(token, customer.id, customer.mobile)
|
||||
// D21 — ask the server which kind of principal this token is, so the
|
||||
// expert-only entries appear for a sales expert. Failure is non-fatal:
|
||||
// the entries simply stay hidden, and the server refuses those routes
|
||||
// for anyone who is not an expert anyway.
|
||||
try {
|
||||
const who = await api.getExpertIdentity()
|
||||
session.setSalesExpert(who.isSalesExpert)
|
||||
} catch {
|
||||
session.setSalesExpert(false)
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
||||
await router.push(redirect ?? { name: 'home' })
|
||||
notify.text = 'با موفقیت وارد شدید'
|
||||
|
||||
Reference in New Issue
Block a user