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>
416 lines
11 KiB
Vue
416 lines
11 KiB
Vue
<!--
|
||
"سفارش مشتریان" — 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">
|
||
.cho__barExtra {
|
||
padding: 0 16px 12px;
|
||
}
|
||
|
||
.cho__body {
|
||
padding-top: 132px;
|
||
padding-bottom: 90px;
|
||
}
|
||
|
||
.cho__list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-top: 16px;
|
||
gap: 11px;
|
||
}
|
||
|
||
.cho__card {
|
||
padding: 16px;
|
||
background: @canvas;
|
||
border: 1px solid @hairline;
|
||
border-radius: 18px;
|
||
cursor: pointer;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.cho__card:active {
|
||
transform: scale(0.99);
|
||
}
|
||
|
||
.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: @inkMuted;
|
||
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: @inkMuted;
|
||
}
|
||
|
||
.cho__status {
|
||
padding: 5px 14px;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #06b6d4;
|
||
background: #ecfeff;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cho__divider {
|
||
height: 1px;
|
||
background: @hairline;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.cho__bottom {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.cho__customer {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: @inkMuted;
|
||
}
|
||
|
||
.cho__more,
|
||
.cho__sentinel {
|
||
padding: 12px 0;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: @inkMuted;
|
||
}
|
||
|
||
.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: @inkMuted;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.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;
|
||
gap: 2px;
|
||
}
|
||
|
||
.cho__row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 11px 14px;
|
||
background: @canvasSoft;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.cho__row dt {
|
||
font-size: 13px;
|
||
color: @inkMuted;
|
||
}
|
||
|
||
.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>
|