Files
tehran_tala/apps/user-app/src/views/expert/ChildOrdersView.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

420 lines
11 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 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">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<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 { 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 { 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: fade(#06b6d4, 12%);
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: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>