Files
tehran_taj/apps/user-app-v2/src/views/expert/ChildOrdersView.vue
T
mahmoodandClaude Sonnet 5.5 6514b05d18 fix(money,orders): wage/gram-debt parity, atomic document confirm, expert edit, made-to-order, QA fixes
- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync
- Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency)
- Settlement document and expert inputs validated (400 instead of 500)
- Expert: edit / activate-deactivate own customers, adjust raw weights only
- Made-to-order flow in the app, PreOrder.quantity migration
- Order grids: customer column, sort keys; panel select search; pre-order quantity save
- Rebuilt release artifacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:09:02 +03:30

626 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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)
The reference's edit-order drawer behind that tap (toEditOrder ->
PUT /order/child/{id}) is back, in a form that keeps the reason it was held
back: it used to recalculate an order's weights and prices from values the
CLIENT sent. Here the expert enters ONLY the real raw weight of each piece;
the server applies the making charge the order was placed at, recomputes the
totals and the customer's debt, and refuses anyone but the expert the order
belongs to (OrdersService.expertAdjust). The figures shown under each field
are a preview of that formula, not what is saved. «ثبت نهایی» hands the order
to the customer.
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="درحال دریافت اطلاعات">
<AppPullRefresh 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>
</AppPullRefresh>
</Spinner>
<Drawer
v-model:visible="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>
<!-- The real weight of each piece. Only the raw weight is sent; the making
charge, totals and debt are worked out by the server, and the figure
shown under each field is a preview of that. -->
<h3 v-if="drafts.length" class="cho__editTitle">وزن واقعی هر عدد (گرم)</h3>
<div v-for="d in drafts" :key="d.id" class="cho__line">
<div class="cho__lineHead">
<span class="cho__lineName">{{ d.title }}</span>
<span class="cho__lineQty" dir="rtl"><bdi>{{ d.qty }}</bdi> عدد · اجرت <bdi>{{ d.netPercent }}٪</bdi></span>
</div>
<InputNumber v-model:value="d.weight" class="cho__input" :min="0.001" :step="0.01" :precision="3" size="large" />
<div class="cho__lineSum" dir="ltr">
{{ grams(preview(d)) }} g
<span class="cho__muted">({{ d.qty }} × {{ grams(unitPreview(d)) }})</span>
</div>
</div>
<div v-if="drafts.length" class="cho__previewTotal">
<span>جمع وزن پس از ثبت</span>
<b dir="ltr">{{ grams(previewTotal) }} گرم</b>
</div>
<p v-if="saveError" class="cho__error">{{ saveError }}</p>
<div v-if="drafts.length" class="cho__actions">
<button type="button" class="cho__save" :disabled="saving" @click="save(false)">ثبت تغییرات</button>
<button type="button" class="cho__final" :disabled="saving" @click="confirmFinal = true">ثبت نهایی</button>
</div>
<p v-if="confirmFinal" class="cho__confirm">
آیا مطمئن به ثبت نهایی هستید؟ پس از آن سفارش تحویل مشتری می‌شود.
<button type="button" class="cho__final" :disabled="saving" @click="save(true)">بله، ثبت نهایی</button>
<button type="button" class="cho__link" @click="confirmFinal = false">بی‌خیال</button>
</p>
<button type="button" class="cho__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer, InputNumber } from 'ant-design-vue'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
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() {
try {
await reload()
} finally {
refreshing.value = false
}
}
/** One line of the order as the expert edits it: the raw weight is the only input. */
interface Draft {
id: string
title: string
qty: number
/** The percent the order was placed at (charge − promotion), kept as it is. */
netPercent: number
weight: number | undefined
}
const drafts = ref<Draft[]>([])
const saving = ref(false)
const saveError = ref('')
const confirmFinal = ref(false)
function openDetail(item: ExpertChildOrder) {
active.value = item
drafts.value = (item.items ?? []).map((i) => ({
id: i.id,
title: [i.productVariant?.product?.title, i.productVariant?.title].filter(Boolean).join(' — ') || 'محصول',
qty: i.quantity,
netPercent: Number(i.wagePercentSnapshot) - Number(i.discountPercentSnapshot),
weight: Number(i.rashWeightGramsSnapshot),
}))
saveError.value = ''
confirmFinal.value = false
detailOpen.value = true
}
/** rash × (1 + net/100) — the server's own formula, shown so the effect is visible before saving. */
const unitPreview = (d: Draft) => (d.weight ?? 0) * (1 + d.netPercent / 100)
const preview = (d: Draft) => unitPreview(d) * d.qty
const previewTotal = computed(() => drafts.value.reduce((s, d) => s + preview(d), 0))
async function save(finalSubmit: boolean) {
if (!active.value || saving.value) return
if (drafts.value.some((d) => !d.weight || d.weight <= 0)) {
saveError.value = 'وزن هر عدد باید بیشتر از صفر باشد.'
return
}
saving.value = true
saveError.value = ''
try {
await api.adjustExpertChildOrder(
active.value.id,
drafts.value.map((d) => ({ id: d.id, rash_weight: d.weight as number })),
finalSubmit,
)
detailOpen.value = false
await reload()
} catch (e) {
// The server's Persian reason (not found, wrong status, accounting busy, …) is in the body.
const text = e instanceof Error ? e.message : ''
const m = text.match(/"message":"([^"]+)"/)
saveError.value = m ? m[1] : 'ثبت تغییرات انجام نشد؛ دوباره تلاش کنید.'
} finally {
saving.value = false
confirmFinal.value = false
}
}
// 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: fade(#06b6d4, 12%);
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__editTitle {
margin: 18px 0 6px;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.cho__line {
padding: 12px 0;
border-bottom: 1px solid @hairline;
}
.cho__lineHead {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
font-size: 12.5px;
}
.cho__lineName {
font-weight: 700;
color: @ink;
}
.cho__lineQty {
color: @inkFaint;
white-space: nowrap;
}
.cho__input {
width: 100%;
}
.cho__lineSum {
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.cho__previewTotal {
display: flex;
justify-content: space-between;
padding: 14px 0 4px;
font-size: 13px;
}
.cho__error {
margin: 10px 0 0;
font-size: 12.5px;
color: #c0392b;
}
.cho__actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.cho__save,
.cho__final {
flex: 1;
padding: 12px;
border: 1px solid @wine;
border-radius: 14px;
background: transparent;
color: @wine;
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.cho__final {
background: @wine;
color: #fff;
}
.cho__save:disabled,
.cho__final:disabled {
opacity: 0.5;
cursor: default;
}
.cho__confirm {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0 0;
font-size: 12.5px;
color: @inkSoft;
}
.cho__link {
border: none;
background: transparent;
color: @inkFaint;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.cho__close {
margin-top: 18px;
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>