Fix wholesale app orders, polish admin/user chrome, and harden order detail UX.
Customer placements now store WHOLESALE (with a migration for prior RETAIL rows), order status opens in a right drawer, logout asks for confirm, and the dashboard profile nudge plus IRANSans panel typeface are tightened for readability. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -49,6 +49,9 @@ const themeTokens = {
|
||||
colorPrimaryBgHover: '#dbeafe',
|
||||
colorPrimaryBorder: '#bfdbfe',
|
||||
colorPrimaryBorderHover: '#93c5fd',
|
||||
// Force ant CSS-in-JS (modals, drawers, selects) onto IRANSans — otherwise
|
||||
// portals keep Adv4's default system stack and look like a second typeface.
|
||||
fontFamily: 'iransans, Tahoma, sans-serif',
|
||||
};
|
||||
|
||||
const layout = useLayoutStore();
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
// Ported from legacy henza_admin/src/assets/scss/ara/farsi.scss
|
||||
// Converted .scss -> .less (no Sass features were used; this file is plain CSS).
|
||||
// Panel typeface: IRANSans only (FaNum). Iran Yekan (p-yek) is no longer loaded.
|
||||
//
|
||||
// CSS bugs fixed here (approved departure from byte-faithful porting — see
|
||||
// docs/ADMIN-PORT-PLAN.md rule 2; listed rather than silently applied):
|
||||
// 1. FIXED: the bold/700 @font-face blocks pointed at the SAME
|
||||
// iran-yekan-400.woff2 file even though iran-yekan-700.woff2 exists, so
|
||||
// bold text rendered in the regular weight. They now load the 700 file.
|
||||
// There is no source file for weight 300 (only 400/700 exist on disk),
|
||||
// so that face is dropped rather than mapped to another weight.
|
||||
// 2. FIXED: `format("woff")` was declared for a .woff2 file (wrong format
|
||||
// hint). Corrected to format("woff2").
|
||||
// 3. FIXED: the `body, *` reset set `font-weight: inherit`, which flattened
|
||||
// every heading's weight (h1..h6 rendered at their parent's weight
|
||||
// instead of bold). Removed; browsers' default heading weights apply.
|
||||
// CSS bugs fixed vs the legacy port (see docs/ADMIN-PORT-PLAN.md rule 2):
|
||||
// 1. FIXED: wrong format("woff") hint on woff2 files → format("woff2").
|
||||
// 2. FIXED: `body, *` no longer sets `font-weight: inherit` (flattened headings).
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-700.woff2") format("woff2");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// IRANSans — shared with the customer app (assets/fonts/iransans/*).
|
||||
// Installed as the panel's primary typeface; 'p-yek' (above) stays loaded as
|
||||
// the fallback in the body rule below rather than being removed, in case
|
||||
// anything still depends on its metrics.
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff') format('woff');
|
||||
}
|
||||
@@ -55,14 +17,25 @@
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Light.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Light.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum.woff') format('woff');
|
||||
}
|
||||
@@ -71,14 +44,25 @@
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Medium.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff') format('woff');
|
||||
}
|
||||
@@ -87,15 +71,18 @@
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Black.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Black.woff') format('woff');
|
||||
}
|
||||
|
||||
body, * {
|
||||
font-family: iransans, p-yek, sans-serif, tahoma;
|
||||
letter-spacing: 0px !important;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
html,
|
||||
body,
|
||||
#app,
|
||||
body *,
|
||||
#app * {
|
||||
font-family: iransans, Tahoma, sans-serif !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.ara-icon-button {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
/* Uploaded file names are Latin filenames; keep them LTR even in an RTL panel. */
|
||||
.ant-upload-list-item-name {
|
||||
font-family: Arial, sans-serif !important;
|
||||
font-family: iransans, Tahoma, sans-serif !important;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
import Icon from '../../components/icon/Icon.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
@@ -104,9 +105,19 @@ const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'ک
|
||||
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
|
||||
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
|
||||
|
||||
async function onLogout() {
|
||||
const left = await auth.logout();
|
||||
if (!left) router.push('/auth/login');
|
||||
function onLogout() {
|
||||
Modal.confirm({
|
||||
title: 'خروج از حساب',
|
||||
content: 'آیا مطمئن هستید که میخواهید از پنل خارج شوید؟',
|
||||
okText: 'خروج',
|
||||
cancelText: 'انصراف',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
async onOk() {
|
||||
const left = await auth.logout();
|
||||
if (!left) router.push('/auth/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ function onClick({ key }: { key: string | number }) {
|
||||
height: 100%;
|
||||
}
|
||||
.nav-header {
|
||||
height: 61px; /* legacy ara/ant.less @layout-header-height */
|
||||
height: 61px; /* matches TheNavbar / content offset in Main.vue */
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -206,18 +206,20 @@ function onClick({ key }: { key: string | number }) {
|
||||
padding: 0;
|
||||
}
|
||||
.nav-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
box-shadow: 0 4px 12px -6px rgba(29, 78, 216, 0.35);
|
||||
}
|
||||
.nav-logo img {
|
||||
width: 64%;
|
||||
width: 92%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
|
||||
@@ -884,10 +884,8 @@ b {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
padding-inline-end: 4px;
|
||||
gap: 12px;
|
||||
// Grow with rows — no tall empty scroll well when the list is short.
|
||||
}
|
||||
.rank-list li {
|
||||
display: flex;
|
||||
|
||||
@@ -30,16 +30,21 @@
|
||||
</template>
|
||||
|
||||
<template #items_len="{ text }">
|
||||
<div v-if="text">{{ text.length }}</div>
|
||||
<div>{{ Array.isArray(text) ? text.length : 0 }}</div>
|
||||
</template>
|
||||
|
||||
<template #user="{ text, record }">
|
||||
<div v-if="text">{{ text.name }}</div>
|
||||
<div v-else>{{ record.customer.fname + ' ' + record.customer.lname }}</div>
|
||||
<template #user="{ text }">
|
||||
<div v-if="text?.name">{{ text.name }}</div>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template #weight="{ text }">
|
||||
<div>{{ formatWeight(text) }}</div>
|
||||
</template>
|
||||
|
||||
<template #total_price="{ text }">
|
||||
<div v-if="text">{{ priceUnit(text) }}</div>
|
||||
<div v-if="Number(text) > 0">{{ priceUnit(text) }}</div>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template #sts="{ text, record }">
|
||||
@@ -50,107 +55,20 @@
|
||||
</template>
|
||||
</XTable>
|
||||
|
||||
<!-- وضعیت سفارش -->
|
||||
<a-modal v-model:open="visibleSts" title="وضعیت سفارش" wrap-class-name="detail-modal">
|
||||
<a-timeline v-if="tempRow">
|
||||
<a-timeline-item
|
||||
v-for="(item, index) in tempRow.activity"
|
||||
:key="index"
|
||||
:color="getColorSts(item)"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div>{{ item.title }}</div>
|
||||
<div v-if="item.proceed">
|
||||
<div class="flex">
|
||||
<div class="flex-1">
|
||||
<a-tag>{{ formatDate(item.uaj) }}</a-tag>
|
||||
</div>
|
||||
<a-popconfirm
|
||||
v-if="index > 0 && hasPermission('order.list.sts.remove')"
|
||||
ok-text="بله"
|
||||
cancel-text="بی خیال"
|
||||
@confirm="removeSts(item)"
|
||||
>
|
||||
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
|
||||
<a-button size="small" danger>حذف وضعیت</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!item.proceed && hasPermission('order.list.sts.submit')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<a-input v-model:value="item.note" placeholder="یادداشت" />
|
||||
<a-button type="text" size="small" @click="setSts(item)">ثبت وضعیت</a-button>
|
||||
</div>
|
||||
<div v-if="item.proceed">{{ item.note }}</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<template #footer>
|
||||
<a-button @click="visibleSts = false">باشه</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
<OrderStatusModal
|
||||
v-model:open="visibleSts"
|
||||
:order="tempRow"
|
||||
:can-submit="hasPermission('order.list.sts.submit')"
|
||||
:can-remove="hasPermission('order.list.sts.remove')"
|
||||
@submit="setSts"
|
||||
@remove="removeSts"
|
||||
/>
|
||||
|
||||
<!-- مشاهده سفارش -->
|
||||
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%" wrap-class-name="detail-modal">
|
||||
<div v-if="tempRow">
|
||||
<a-descriptions>
|
||||
<a-descriptions-item label="مشتری">
|
||||
{{ tempRow.customer.fname }} {{ tempRow.customer.lname }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تلفن همراه">
|
||||
{{ tempRow.customer.phone }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ ثبت">{{ formatDate(tempRow.caj) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-card title="آیتم های سفارش">
|
||||
<div v-for="(item, index) in tempRow.items" :key="index">
|
||||
<a-descriptions>
|
||||
<a-descriptions-item label="نام محصول">
|
||||
<a-tag color="blue">{{ item.title }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وزن نهایی">
|
||||
<a-input-number
|
||||
v-if="editMode && hasPermission('order.list.view.edit')"
|
||||
v-model:value="item.grams"
|
||||
class="w-full mt-0"
|
||||
/>
|
||||
<a-tag v-else color="red">{{ item.grams }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تعداد">
|
||||
<a-input-number
|
||||
v-if="editMode && hasPermission('order.list.view.edit')"
|
||||
v-model:value="item.qty"
|
||||
/>
|
||||
<a-tag v-else color="orange">{{ item.qty }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="توضیحات">
|
||||
<div>{{ item.note }}</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-divider v-if="index < tempRow.items.length - 1" />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
<a-empty v-else />
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<a-button @click="visibleList = false">باشه</a-button>
|
||||
<a-button
|
||||
v-if="editMode && hasPermission('order.list.view.edit')"
|
||||
danger
|
||||
@click="editRow()"
|
||||
>
|
||||
ویرایش سفارش
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
<OrderViewModal
|
||||
v-model:open="visibleList"
|
||||
:order="tempRow"
|
||||
:editable="hasPermission('order.list.view.edit')"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -161,10 +79,12 @@ import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
|
||||
import { buildOrderListModel } from './orderListModel';
|
||||
import OrderViewModal from './OrderViewModal.vue';
|
||||
import OrderStatusModal from './OrderStatusModal.vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import { isBranch } from '../../utils';
|
||||
import api from '../../api/client';
|
||||
import { jdate, priceUnit } from '../../utils/filters';
|
||||
import { priceUnit } from '../../utils/filters';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
@@ -175,14 +95,13 @@ const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const visibleList = ref(false);
|
||||
const visibleSts = ref(false);
|
||||
const editMode = ref(false);
|
||||
|
||||
const is_branch = computed(() => isBranch());
|
||||
|
||||
const formatDate = (v: string | null) => (v ? jdate(v) : '');
|
||||
|
||||
function getColorSts(row: { proceed?: boolean }) {
|
||||
return row.proceed ? 'green' : 'gray';
|
||||
function formatWeight(v: unknown) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return `${n.toFixed(2)} گرم`;
|
||||
}
|
||||
|
||||
async function showSts(row: Record<string, any>) {
|
||||
@@ -238,19 +157,6 @@ async function removeSts(row: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function editRow() {
|
||||
try {
|
||||
// Legacy's PUT call is commented out in the original too — it only toggles
|
||||
// the UI and re-fetches. Ported as-is; the edit is not persisted.
|
||||
editMode.value = !editMode.value;
|
||||
visibleList.value = !visibleList.value;
|
||||
message.info('ویرایش موفق آمیز');
|
||||
tbl.value?.fetch();
|
||||
} catch (e) {
|
||||
message.error('خطا در ویرایش');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewOrder(row: Record<string, any>) {
|
||||
tempRow.value = await loadDetail(row.id);
|
||||
visibleList.value = true;
|
||||
@@ -276,4 +182,7 @@ async function viewPdf(id: string) {
|
||||
.mr-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
.text-muted {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,84 +54,16 @@
|
||||
</template>
|
||||
</XTable>
|
||||
|
||||
<!-- وضعیت سفارش -->
|
||||
<a-modal v-model:open="visibleSts" title="وضعیت سفارش" wrap-class-name="detail-modal">
|
||||
<a-timeline v-if="tempRow">
|
||||
<a-timeline-item
|
||||
v-for="(item, index) in tempRow.activity"
|
||||
:key="index"
|
||||
:color="getColorSts(item)"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div>{{ item.title }}</div>
|
||||
<div v-if="item.proceed">
|
||||
<div class="flex">
|
||||
<div class="flex-1">
|
||||
<a-tag>{{ formatDate(item.uaj) }}</a-tag>
|
||||
</div>
|
||||
<a-popconfirm
|
||||
v-if="index > 0 && hasPermission('order.omde.state.remove')"
|
||||
ok-text="بله"
|
||||
cancel-text="بی خیال"
|
||||
@confirm="removeSts(item)"
|
||||
>
|
||||
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
|
||||
<a-button size="small" danger>حذف وضعیت</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!item.proceed && hasPermission('order.omde.state.submit')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<a-input v-model:value="item.note" placeholder="یادداشت" />
|
||||
<a-button type="text" size="small" @click="setSts(item)">ثبت وضعیت</a-button>
|
||||
</div>
|
||||
<div v-if="item.proceed">{{ item.note }}</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<template #footer>
|
||||
<a-button @click="visibleSts = false">باشه</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
<OrderStatusModal
|
||||
v-model:open="visibleSts"
|
||||
:order="tempRow"
|
||||
:can-submit="hasPermission('order.omde.state.submit')"
|
||||
:can-remove="hasPermission('order.omde.state.remove')"
|
||||
@submit="setSts"
|
||||
@remove="removeSts"
|
||||
/>
|
||||
|
||||
<!-- مشاهده سفارش -->
|
||||
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%" wrap-class-name="detail-modal">
|
||||
<div v-if="tempRow">
|
||||
<a-descriptions>
|
||||
<a-descriptions-item label="مشتری">
|
||||
{{ tempRow.customer.fname }} {{ tempRow.customer.lname }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تلفن همراه">{{ tempRow.customer.phone }}</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ ثبت">{{ formatDate(tempRow.caj) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-card title="آیتم های سفارش">
|
||||
<div v-for="(item, index) in tempRow.items" :key="index">
|
||||
<a-descriptions>
|
||||
<a-descriptions-item label="نام محصول">
|
||||
<a-tag color="blue">{{ item.title }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وزن نهایی">
|
||||
<a-tag color="red">{{ item.grams }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تعداد">
|
||||
<a-tag color="orange">{{ item.qty }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-divider v-if="index < tempRow.items.length - 1" />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
<a-empty v-else />
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="visibleList = false">باشه</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
<OrderViewModal v-model:open="visibleList" :order="tempRow" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -142,9 +74,10 @@ import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
|
||||
import { buildOmdeListModel } from './OmdeListModel';
|
||||
import OrderViewModal from './OrderViewModal.vue';
|
||||
import OrderStatusModal from './OrderStatusModal.vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import api from '../../api/client';
|
||||
import { jdate } from '../../utils/filters';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
@@ -156,17 +89,11 @@ const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const visibleList = ref(false);
|
||||
const visibleSts = ref(false);
|
||||
|
||||
const formatDate = (v: string | null) => (v ? jdate(v) : '');
|
||||
|
||||
/** legacy getCity(): resolve a city id through the users store lookup */
|
||||
function getCity(id: string | number) {
|
||||
return users.cityFilters[id] ?? '-';
|
||||
}
|
||||
|
||||
function getColorSts(row: { proceed?: boolean }) {
|
||||
return row.proceed ? 'green' : 'gray';
|
||||
}
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
const { data } = await api.get(`admin/orders/${id}`);
|
||||
return data;
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
<!--
|
||||
Shared «وضعیت سفارش» sheet — right-side drawer with a calm timeline
|
||||
(current / done / next). Used by retail, wholesale and tamin lists.
|
||||
-->
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="open"
|
||||
title="وضعیت سفارش"
|
||||
placement="right"
|
||||
:width="420"
|
||||
root-class-name="order-sts-drawer"
|
||||
destroy-on-close
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<div v-if="order" class="os">
|
||||
<header class="os__hero">
|
||||
<div class="os__heroText">
|
||||
<div class="os__number">{{ order.orderNumber || '—' }}</div>
|
||||
<div class="os__customer">{{ customerLabel(order.customer) }}</div>
|
||||
</div>
|
||||
<a-tag class="os__badge" :color="statusTagColor(order.status)">
|
||||
{{ order.sts_str || '—' }}
|
||||
</a-tag>
|
||||
</header>
|
||||
|
||||
<div class="os__progress" aria-hidden="true">
|
||||
<div class="os__progressBar">
|
||||
<div class="os__progressFill" :style="{ width: `${progressPct}%` }" />
|
||||
</div>
|
||||
<div class="os__progressMeta">
|
||||
<span>{{ doneCount }} از {{ steps.length }} مرحله</span>
|
||||
<span v-if="nextStep">بعدی: {{ nextStep.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="os__steps">
|
||||
<li
|
||||
v-for="(item, index) in steps"
|
||||
:key="item.status || index"
|
||||
class="os__step"
|
||||
:class="{
|
||||
'is-done': item.proceed,
|
||||
'is-next': isNext(index),
|
||||
'is-future': !item.proceed && !isNext(index),
|
||||
}"
|
||||
>
|
||||
<div class="os__rail">
|
||||
<span class="os__dot" :class="{ 'is-check': item.proceed }">
|
||||
<span v-if="item.proceed" class="os__dotGlyph">✓</span>
|
||||
<span v-else class="os__dotGlyph">{{ index + 1 }}</span>
|
||||
</span>
|
||||
<span v-if="index < steps.length - 1" class="os__line" />
|
||||
</div>
|
||||
|
||||
<div class="os__card">
|
||||
<div class="os__cardHead">
|
||||
<span class="os__title">{{ item.title }}</span>
|
||||
<a-tag v-if="item.proceed" color="success" class="os__chip">انجام شد</a-tag>
|
||||
<a-tag v-else-if="isNext(index)" color="processing" class="os__chip">مرحله بعد</a-tag>
|
||||
<a-tag v-else class="os__chip">در انتظار</a-tag>
|
||||
</div>
|
||||
|
||||
<template v-if="item.proceed">
|
||||
<div class="os__doneRow">
|
||||
<span v-if="item.uaj" class="os__date">{{ formatDate(item.uaj) }}</span>
|
||||
<span v-else class="os__date os__date--mute">بدون تاریخ</span>
|
||||
<a-popconfirm
|
||||
v-if="index > 0 && canRemove"
|
||||
ok-text="بله"
|
||||
cancel-text="انصراف"
|
||||
title="این وضعیت حذف شود و سفارش به مرحله قبل برگردد؟"
|
||||
@confirm="emit('remove', item)"
|
||||
>
|
||||
<a-button size="small" danger type="link" class="os__remove">حذف وضعیت</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
<p v-if="item.note" class="os__note">{{ item.note }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isNext(index) && canSubmit">
|
||||
<div class="os__action">
|
||||
<a-textarea
|
||||
v-model:value="item.note"
|
||||
:rows="2"
|
||||
placeholder="یادداشت (اختیاری)"
|
||||
class="os__textarea"
|
||||
/>
|
||||
<a-button type="primary" block size="middle" @click="emit('submit', item)">
|
||||
ثبت «{{ item.title }}»
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!item.proceed" class="os__hint">بعد از تکمیل مراحل قبلی فعال میشود.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<footer class="os__footer">
|
||||
<a-button @click="onOpenChange(false)">بستن</a-button>
|
||||
</footer>
|
||||
</div>
|
||||
<a-empty v-else description="اطلاعات وضعیت بارگذاری نشد" />
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { jdate } from '../../utils/filters';
|
||||
|
||||
export interface StatusStep {
|
||||
status?: string;
|
||||
title: string;
|
||||
proceed?: boolean;
|
||||
uaj?: string | null;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
order?: Record<string, any> | null;
|
||||
canSubmit?: boolean;
|
||||
canRemove?: boolean;
|
||||
}>(),
|
||||
{ order: null, canSubmit: false, canRemove: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
submit: [item: StatusStep];
|
||||
remove: [item: StatusStep];
|
||||
}>();
|
||||
|
||||
const steps = computed<StatusStep[]>(() =>
|
||||
Array.isArray(props.order?.activity) ? props.order!.activity : [],
|
||||
);
|
||||
|
||||
const doneCount = computed(() => steps.value.filter((s) => s.proceed).length);
|
||||
|
||||
const progressPct = computed(() => {
|
||||
if (!steps.value.length) return 0;
|
||||
return Math.round((doneCount.value / steps.value.length) * 100);
|
||||
});
|
||||
|
||||
const nextStep = computed(() => steps.value.find((s) => !s.proceed) ?? null);
|
||||
|
||||
function isNext(index: number) {
|
||||
const firstOpen = steps.value.findIndex((s) => !s.proceed);
|
||||
return firstOpen === index;
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
emit('update:open', value);
|
||||
}
|
||||
|
||||
function formatDate(v: string | null | undefined) {
|
||||
return v ? jdate(v) : '';
|
||||
}
|
||||
|
||||
function customerLabel(c?: { fullName?: string; name?: string; fname?: string; lname?: string }) {
|
||||
if (!c) return '';
|
||||
const full = (c.fullName || c.name || '').trim();
|
||||
if (full) return full;
|
||||
return [c.fname, c.lname].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
function statusTagColor(status?: string) {
|
||||
switch (status) {
|
||||
case 'SUSPENDED':
|
||||
return 'gold';
|
||||
case 'CANCELLED':
|
||||
return 'error';
|
||||
case 'DELIVERED_TO_CUSTOMER':
|
||||
return 'success';
|
||||
case 'DELIVERED_TO_AGENT':
|
||||
return 'cyan';
|
||||
case 'PREPARING':
|
||||
return 'orange';
|
||||
case 'SOURCING':
|
||||
return 'warning';
|
||||
case 'ADMIN_APPROVED':
|
||||
return 'purple';
|
||||
case 'PLACED':
|
||||
return 'processing';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.os {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.os__hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: rgba(24, 24, 27, 0.03);
|
||||
}
|
||||
|
||||
.os__heroText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.os__number {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.os__customer {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.48);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.os__badge {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.os__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.os__progressBar {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.os__progressFill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #2563eb, #06b6d4);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.os__progressMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.os__steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 2px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.os__step {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.os__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.os__dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
border: 1.5px solid transparent;
|
||||
z-index: 1;
|
||||
/* Isolate so RTL drawer does not shift glyph metrics. */
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* IRANSans FaNum digits sit high in the em-box; nudge down for optical center. */
|
||||
.os__dotGlyph {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.os__dot.is-check .os__dotGlyph {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.os__line {
|
||||
flex: 1;
|
||||
width: 1.5px;
|
||||
min-height: 14px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.os__step.is-done .os__dot {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.os__step.is-done .os__line {
|
||||
background: #bbf7d0;
|
||||
}
|
||||
|
||||
.os__step.is-next .os__dot {
|
||||
background: #fff;
|
||||
color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.os__card {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.os__step.is-next .os__card {
|
||||
background: #fff;
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.os__step.is-future .os__card {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.os__cardHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.os__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.os__chip {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
border-radius: 999px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.os__doneRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.os__date {
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.os__date--mute {
|
||||
color: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.os__remove {
|
||||
padding-inline: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.os__note {
|
||||
margin: 6px 0 0;
|
||||
padding: 7px 9px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.58);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.os__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.os__textarea {
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.os__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.os__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Drawer teleports to body — chrome outside scoped CSS. -->
|
||||
<style lang="less">
|
||||
.order-sts-drawer.ant-drawer-right .ant-drawer-content-wrapper {
|
||||
max-width: min(420px, 100vw);
|
||||
}
|
||||
|
||||
.order-sts-drawer .ant-drawer-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.order-sts-drawer .ant-drawer-header-title .ant-drawer-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
.order-sts-drawer .ant-drawer-body {
|
||||
padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-sts-drawer .ant-drawer-close {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.order-sts-drawer.ant-drawer-right .ant-drawer-content-wrapper {
|
||||
width: 100% !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,413 @@
|
||||
<!--
|
||||
Shared «مشاهده سفارش» modal for retail (تک) and wholesale (عمده) lists.
|
||||
Shows order meta, coloured status, line items with image preview.
|
||||
-->
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="مشاهده سفارش"
|
||||
:footer="null"
|
||||
width="920px"
|
||||
wrap-class-name="detail-modal order-view-modal"
|
||||
destroy-on-close
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<div v-if="order" class="ov">
|
||||
<header class="ov__hero">
|
||||
<div class="ov__heroMain">
|
||||
<h2 class="ov__number">{{ order.orderNumber || '—' }}</h2>
|
||||
<div class="ov__heroMeta">
|
||||
<span>{{ formatDate(order.caj) }}</span>
|
||||
<span v-if="order.items?.length">{{ order.items.length }} آیتم</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-tag class="ov__status" :color="statusTagColor(order.status)">
|
||||
{{ order.sts_str || '—' }}
|
||||
</a-tag>
|
||||
</header>
|
||||
|
||||
<section class="ov__facts">
|
||||
<div class="ov__fact">
|
||||
<span class="ov__factLabel">مشتری</span>
|
||||
<strong class="ov__factValue">{{ customerLabel(order.customer) }}</strong>
|
||||
</div>
|
||||
<div class="ov__fact">
|
||||
<span class="ov__factLabel">تلفن همراه</span>
|
||||
<strong class="ov__factValue ov__factValue--ltr">
|
||||
{{ order.customer?.phone || order.customer?.mobile || '—' }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="ov__fact">
|
||||
<span class="ov__factLabel">وزن کل</span>
|
||||
<strong class="ov__factValue">{{ formatWeight(order.totalGoldWeightGrams) }}</strong>
|
||||
</div>
|
||||
<div class="ov__fact">
|
||||
<span class="ov__factLabel">جمع ریالی</span>
|
||||
<strong class="ov__factValue">
|
||||
<template v-if="Number(order.total_price) > 0">{{ priceUnit(order.total_price) }}</template>
|
||||
<template v-else>—</template>
|
||||
</strong>
|
||||
</div>
|
||||
<div v-if="order.user?.name" class="ov__fact">
|
||||
<span class="ov__factLabel">مسئول فروش</span>
|
||||
<strong class="ov__factValue">{{ order.user.name }}</strong>
|
||||
</div>
|
||||
<div v-if="order.branch?.name" class="ov__fact">
|
||||
<span class="ov__factLabel">شعبه</span>
|
||||
<strong class="ov__factValue">{{ order.branch.name }}</strong>
|
||||
</div>
|
||||
<div v-if="order.confirmed_str" class="ov__fact">
|
||||
<span class="ov__factLabel">تایید مشتری</span>
|
||||
<strong class="ov__factValue">{{ order.confirmed_str }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ov__items">
|
||||
<div class="ov__itemsHead">
|
||||
<h3 class="ov__itemsTitle">آیتمهای سفارش</h3>
|
||||
<span class="ov__itemsCount">{{ order.items?.length || 0 }} مورد</span>
|
||||
</div>
|
||||
|
||||
<div v-if="order.items?.length" class="ov__list">
|
||||
<article v-for="item in order.items" :key="item.id" class="ov__item">
|
||||
<div class="ov__thumb">
|
||||
<a-image
|
||||
v-if="item.image"
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
:width="72"
|
||||
:height="72"
|
||||
class="ov__thumbImg"
|
||||
/>
|
||||
<div v-else class="ov__thumbEmpty" aria-hidden="true">بدون تصویر</div>
|
||||
</div>
|
||||
|
||||
<div class="ov__itemBody">
|
||||
<div class="ov__itemTitle">{{ item.title || '—' }}</div>
|
||||
<div v-if="item.variant_title" class="ov__itemVariant">{{ item.variant_title }}</div>
|
||||
<div class="ov__itemStats">
|
||||
<span class="ov__pill">
|
||||
<template v-if="editable && editMode">
|
||||
<a-input-number v-model:value="item.qty" size="small" :min="1" />
|
||||
</template>
|
||||
<template v-else>{{ item.qty }} عدد</template>
|
||||
</span>
|
||||
<span class="ov__pill ov__pill--warn">
|
||||
<template v-if="editable && editMode">
|
||||
<a-input-number v-model:value="item.grams" size="small" :min="0" :step="0.0001" />
|
||||
<i>گرم</i>
|
||||
</template>
|
||||
<template v-else>{{ formatWeight(item.grams) }}</template>
|
||||
</span>
|
||||
<span v-if="item.rash" class="ov__pill ov__pill--mute">
|
||||
خام {{ Number(item.rash).toFixed(4) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.note" class="ov__itemNote">{{ item.note }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else description="آیتمی ثبت نشده است" />
|
||||
</section>
|
||||
|
||||
<footer class="ov__footer">
|
||||
<a-button @click="onOpenChange(false)">بستن</a-button>
|
||||
<a-button
|
||||
v-if="editable"
|
||||
:type="editMode ? 'primary' : 'default'"
|
||||
danger
|
||||
@click="editMode = !editMode"
|
||||
>
|
||||
{{ editMode ? 'پایان ویرایش' : 'ویرایش سفارش' }}
|
||||
</a-button>
|
||||
</footer>
|
||||
</div>
|
||||
<a-empty v-else />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { jdate, priceUnit } from '../../utils/filters';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
order?: Record<string, any> | null;
|
||||
/** Show the legacy inline-edit toggle (retail list only). */
|
||||
editable?: boolean;
|
||||
}>(),
|
||||
{ order: null, editable: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
|
||||
const editMode = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) editMode.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
emit('update:open', value);
|
||||
}
|
||||
|
||||
function formatDate(v: string | null | undefined) {
|
||||
return v ? jdate(v) : '';
|
||||
}
|
||||
|
||||
function formatWeight(v: unknown) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return `${n.toFixed(2)} گرم`;
|
||||
}
|
||||
|
||||
function customerLabel(c?: { fullName?: string; name?: string; fname?: string; lname?: string }) {
|
||||
if (!c) return '—';
|
||||
const full = (c.fullName || c.name || '').trim();
|
||||
if (full) return full;
|
||||
return [c.fname, c.lname].filter(Boolean).join(' ').trim() || '—';
|
||||
}
|
||||
|
||||
function statusTagColor(status?: string) {
|
||||
switch (status) {
|
||||
case 'SUSPENDED':
|
||||
return 'gold';
|
||||
case 'CANCELLED':
|
||||
return 'error';
|
||||
case 'DELIVERED_TO_CUSTOMER':
|
||||
return 'success';
|
||||
case 'DELIVERED_TO_AGENT':
|
||||
return 'cyan';
|
||||
case 'PREPARING':
|
||||
return 'orange';
|
||||
case 'SOURCING':
|
||||
return 'warning';
|
||||
case 'ADMIN_APPROVED':
|
||||
return 'purple';
|
||||
case 'PLACED':
|
||||
return 'processing';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ov {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: -8px -4px 0;
|
||||
}
|
||||
|
||||
.ov__hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ov__number {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ov__heroMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ov__status {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ov__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ov__fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ov__factLabel {
|
||||
font-size: 11.5px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ov__factValue {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.ov__factValue--ltr {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ov__itemsHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ov__itemsTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ov__itemsCount {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ov__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: min(48vh, 420px);
|
||||
overflow: auto;
|
||||
padding-inline-end: 2px;
|
||||
}
|
||||
|
||||
.ov__item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ov__thumb {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ov__thumb :deep(.ant-image),
|
||||
.ov__thumb :deep(.ant-image-img),
|
||||
.ov__thumbImg {
|
||||
width: 72px !important;
|
||||
height: 72px !important;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ov__thumbEmpty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ov__itemBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ov__itemTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.ov__itemVariant {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ov__itemStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ov__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.ov__pill--warn {
|
||||
background: rgba(234, 88, 12, 0.12);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.ov__pill--mute {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ov__pill i {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ov__itemNote {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.ov__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -41,84 +41,16 @@
|
||||
</template>
|
||||
</XTable>
|
||||
|
||||
<!-- وضعیت سفارش -->
|
||||
<a-modal v-model:open="visibleSts" title="وضعیت سفارش" wrap-class-name="detail-modal">
|
||||
<a-timeline v-if="tempRow">
|
||||
<a-timeline-item
|
||||
v-for="(item, index) in tempRow.activity"
|
||||
:key="index"
|
||||
:color="getColorSts(item)"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div>{{ item.title }}</div>
|
||||
<div v-if="item.proceed">
|
||||
<div class="flex">
|
||||
<div class="flex-1">
|
||||
<a-tag>{{ formatDate(item.uaj) }}</a-tag>
|
||||
</div>
|
||||
<a-popconfirm
|
||||
v-if="index > 0 && hasPermission('order.tamin.state.remove')"
|
||||
ok-text="بله"
|
||||
cancel-text="بی خیال"
|
||||
@confirm="removeSts(item)"
|
||||
>
|
||||
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
|
||||
<a-button size="small" danger>حذف وضعیت</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!item.proceed && hasPermission('order.tamin.state.submit')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<a-input v-model:value="item.note" placeholder="یادداشت" />
|
||||
<a-button type="text" size="small" @click="setSts(item)">ثبت وضعیت</a-button>
|
||||
</div>
|
||||
<div v-if="item.proceed">{{ item.note }}</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<template #footer>
|
||||
<a-button @click="visibleSts = false">باشه</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
<OrderStatusModal
|
||||
v-model:open="visibleSts"
|
||||
:order="tempRow"
|
||||
:can-submit="hasPermission('order.tamin.state.submit')"
|
||||
:can-remove="hasPermission('order.tamin.state.remove')"
|
||||
@submit="setSts"
|
||||
@remove="removeSts"
|
||||
/>
|
||||
|
||||
<!-- مشاهده سفارش -->
|
||||
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%" wrap-class-name="detail-modal">
|
||||
<div v-if="tempRow">
|
||||
<a-descriptions>
|
||||
<a-descriptions-item label="مشتری">
|
||||
{{ tempRow.customer.fname }} {{ tempRow.customer.lname }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تلفن همراه">{{ tempRow.customer.phone }}</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ ثبت">{{ formatDate(tempRow.caj) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-card title="آیتم های سفارش">
|
||||
<div v-for="(item, index) in tempRow.items" :key="index">
|
||||
<a-descriptions>
|
||||
<a-descriptions-item label="نام محصول">
|
||||
<a-tag color="blue">{{ item.title }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وزن نهایی">
|
||||
<a-tag color="red">{{ item.grams }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تعداد">
|
||||
<a-tag color="orange">{{ item.qty }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-divider v-if="index < tempRow.items.length - 1" />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
<a-empty v-else />
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="visibleList = false">باشه</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
<OrderViewModal v-model:open="visibleList" :order="tempRow" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -129,9 +61,10 @@ import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
|
||||
import { buildTaminListModel } from './TaminListModel';
|
||||
import OrderViewModal from './OrderViewModal.vue';
|
||||
import OrderStatusModal from './OrderStatusModal.vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import api from '../../api/client';
|
||||
import { jdate } from '../../utils/filters';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
@@ -143,12 +76,6 @@ const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const visibleList = ref(false);
|
||||
const visibleSts = ref(false);
|
||||
|
||||
const formatDate = (v: string | null) => (v ? jdate(v) : '');
|
||||
|
||||
function getColorSts(row: { proceed?: boolean }) {
|
||||
return row.proceed ? 'green' : 'gray';
|
||||
}
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
const { data } = await api.get(`admin/orders/${id}`);
|
||||
return data;
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
// Ported from legacy henza_admin/src/views/order/orderListModel.js
|
||||
//
|
||||
// Column keys, Farsi titles, filter types, slots and the conditional
|
||||
// 'order.tamin.state' block are preserved exactly. Only two things changed:
|
||||
// - the url is the new clean REST path instead of the absolute legacy one
|
||||
// ('https://henzagold.com/oapi/admin/single-order')
|
||||
// - roles come from the Pinia users store instead of Vuex
|
||||
// Columns aligned with the current Order payload (fullName, orderNumber,
|
||||
// gold weight, flat rial) so the retail list is not empty for name / total.
|
||||
import XTbl, { Xtc } from '../../components/x-table/model';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
export function buildOrderListModel(): XTbl {
|
||||
const tbl = new XTbl('admin/orders/list', 'لیست سفارش های تک فروشی');
|
||||
tbl.add(new Xtc('id', 'شماره').filterNumber());
|
||||
tbl.add(new Xtc('customer.fname', 'نام مشتری').filterKey('customer.fullName').filterText());
|
||||
tbl.add(new Xtc('customer.lname', 'نام خانوادگی').filterKey('customer.fullName').filterText());
|
||||
tbl.add(new Xtc('total_price', 'جمع کل سفارش').renderSlot('total_price').filterText());
|
||||
tbl.add(new Xtc('orderNumber', 'شماره سفارش').filterText());
|
||||
tbl.add(new Xtc('customer.fullName', 'نام مشتری').filterText());
|
||||
tbl.add(new Xtc('customer.mobile', 'موبایل').filterText());
|
||||
tbl.add(
|
||||
new Xtc('totalGoldWeightGrams', 'وزن کل (گرم)')
|
||||
.renderSlot('weight')
|
||||
.filterText(),
|
||||
);
|
||||
tbl.add(
|
||||
new Xtc('total_price', 'جمع ریالی')
|
||||
.renderSlot('total_price')
|
||||
.filterKey('flatRialCharge')
|
||||
.filterText(),
|
||||
);
|
||||
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
|
||||
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
|
||||
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort());
|
||||
tbl.add(new Xtc('user', 'ثبت کننده').renderSlot('user').noSort());
|
||||
tbl.add(new Xtc('user', 'مسئول فروش').renderSlot('user').noSort());
|
||||
|
||||
// legacy: gated on store.state.users.roles.includes('order.tamin.state')
|
||||
const users = useUsersStore();
|
||||
if (users.roles && users.roles.includes('order.tamin.state')) {
|
||||
if (users.roles && (users.roles.includes('order.list.sts') || users.roles.includes('order.tamin.state'))) {
|
||||
tbl.add(new Xtc('sts_str', 'وضعیت').renderSlot('sts').noSort());
|
||||
tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- App-placed orders were defaulting to RETAIL while operators watch the
|
||||
-- WHOLESALE (عمده) list. Move every existing RETAIL row onto WHOLESALE so they
|
||||
-- appear in «لیست سفارش های عمده». Going forward, customer POST /orders also
|
||||
-- stores WHOLESALE (see OrdersService.createOrder).
|
||||
--
|
||||
-- Legitimate admin-created orders were already WHOLESALE. RETAIL was only the
|
||||
-- mistaken app default (and optional seed demos).
|
||||
|
||||
UPDATE "Order"
|
||||
SET "channel" = 'WHOLESALE'
|
||||
WHERE "channel" = 'RETAIL';
|
||||
@@ -545,7 +545,9 @@ async function main() {
|
||||
orderNumber: 'ORD-SEED-0002',
|
||||
customerId: customer2.id,
|
||||
branchId: branchTehran.id,
|
||||
channel: OrderChannel.RETAIL,
|
||||
// App / customer channel is wholesale (عمده); keep seed aligned with
|
||||
// OrdersService customer placements and the omde admin list.
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
status: 'PLACED',
|
||||
totalGoldWeightGrams: computed,
|
||||
wageWeightGrams: (parseFloat(computed) - parseFloat(rash)).toFixed(4),
|
||||
@@ -577,7 +579,7 @@ async function main() {
|
||||
});
|
||||
await prisma.order.updateMany({
|
||||
where: { orderNumber: 'ORD-SEED-0002' },
|
||||
data: { channel: OrderChannel.RETAIL },
|
||||
data: { channel: OrderChannel.WHOLESALE },
|
||||
});
|
||||
|
||||
// ── A delivered order + posted debt, so the dashboard shows real numbers ──
|
||||
|
||||
@@ -36,6 +36,23 @@ const STATUS_FLOW: OrderStatus[] = [
|
||||
OrderStatus.DELIVERED_TO_CUSTOMER,
|
||||
];
|
||||
|
||||
/** Shared include for list + detail rows (images needed for the view modal). */
|
||||
const ORDER_ROW_INCLUDE = {
|
||||
customer: true,
|
||||
branch: true,
|
||||
salesAgent: true,
|
||||
items: {
|
||||
include: {
|
||||
productVariant: {
|
||||
include: {
|
||||
image: true,
|
||||
product: { include: { images: { orderBy: { sortOrder: 'asc' as const }, take: 1 } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.OrderInclude;
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
constructor(
|
||||
@@ -90,11 +107,7 @@ export class AdminOrdersService {
|
||||
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
include: ORDER_ROW_INCLUDE,
|
||||
}),
|
||||
this.prisma.order.count({ where: scoped }),
|
||||
]);
|
||||
@@ -105,16 +118,18 @@ export class AdminOrdersService {
|
||||
/**
|
||||
* Shapes a row the way the ported list view reads it.
|
||||
*
|
||||
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
|
||||
* so the ported template bindings keep working unchanged.
|
||||
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`,
|
||||
* `input`, `customer.name`) so the ported template bindings keep working.
|
||||
*/
|
||||
private toRow(o: Prisma.OrderGetPayload<{
|
||||
include: {
|
||||
customer: true;
|
||||
branch: true;
|
||||
items: { include: { productVariant: { include: { product: true } } } };
|
||||
};
|
||||
}>) {
|
||||
private toRow(o: Prisma.OrderGetPayload<{ include: typeof ORDER_ROW_INCLUDE }>) {
|
||||
const first = o.customer.firstName?.trim() || '';
|
||||
const last = o.customer.lastName?.trim() || '';
|
||||
// Empty-string parts must not win over fullName (`??` would keep "").
|
||||
const fname = first || o.customer.fullName;
|
||||
const lname = first ? last : '';
|
||||
const weight = Number(o.totalGoldWeightGrams);
|
||||
const rial = Number(o.flatRialCharge);
|
||||
|
||||
return {
|
||||
id: o.id,
|
||||
orderNumber: o.orderNumber,
|
||||
@@ -122,30 +137,42 @@ export class AdminOrdersService {
|
||||
sts_str: STATUS_LABELS[o.status],
|
||||
caj: o.createdAt,
|
||||
uaj: o.updatedAt,
|
||||
total_price: o.flatRialCharge,
|
||||
totalGoldWeightGrams: o.totalGoldWeightGrams,
|
||||
wageWeightGrams: o.wageWeightGrams,
|
||||
total_price: rial,
|
||||
// Omde list column dataIndex is still `input` (legacy weight total).
|
||||
input: weight,
|
||||
totalGoldWeightGrams: weight,
|
||||
wageWeightGrams: Number(o.wageWeightGrams),
|
||||
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
|
||||
deliveredAt: o.deliveredAt,
|
||||
branch_id: o.branchId,
|
||||
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
|
||||
customer: {
|
||||
id: o.customer.id,
|
||||
// Legacy split names into fname/lname; the new schema has one fullName.
|
||||
// Both are exposed so ported templates binding either keep working.
|
||||
fname: o.customer.firstName ?? o.customer.fullName,
|
||||
lname: o.customer.lastName ?? '',
|
||||
fname,
|
||||
lname,
|
||||
name: o.customer.fullName,
|
||||
fullName: o.customer.fullName,
|
||||
phone: o.customer.mobile,
|
||||
mobile: o.customer.mobile,
|
||||
},
|
||||
items: o.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.productVariant.product.title,
|
||||
variant_title: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
grams: it.computedWeightGrams,
|
||||
rash: it.rashWeightGramsSnapshot,
|
||||
})),
|
||||
// «ثبت کننده» / sales credit — set once at placement.
|
||||
user: o.salesAgent
|
||||
? { id: o.salesAgent.id, name: o.salesAgent.fullName, mobile: o.salesAgent.mobile }
|
||||
: null,
|
||||
items: o.items.map((it) => {
|
||||
const image =
|
||||
it.productVariant.image?.url ?? it.productVariant.product.images[0]?.url ?? null;
|
||||
return {
|
||||
id: it.id,
|
||||
title: it.productVariant.product.title,
|
||||
variant_title: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
grams: Number(it.computedWeightGrams),
|
||||
rash: Number(it.rashWeightGramsSnapshot),
|
||||
image,
|
||||
note: '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,11 +222,7 @@ export class AdminOrdersService {
|
||||
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
include: ORDER_ROW_INCLUDE,
|
||||
}),
|
||||
this.prisma.order.count({ where: scoped }),
|
||||
]);
|
||||
@@ -240,9 +263,7 @@ export class AdminOrdersService {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
...ORDER_ROW_INCLUDE,
|
||||
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,7 +47,10 @@ export class CreateOrderDto {
|
||||
/**
|
||||
* Which sales channel placed the order. Legacy had no such field because the
|
||||
* two channels lived in separate tables (orders vs orders_orders); the one
|
||||
* Order model carries the distinction here. Defaults to RETAIL.
|
||||
* Order model carries the distinction here.
|
||||
*
|
||||
* Customer-app placements always store WHOLESALE (legacy mobile setOrder),
|
||||
* regardless of this field. Operator placements default to RETAIL when omitted.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsEnum(OrderChannel)
|
||||
|
||||
@@ -141,7 +141,13 @@ export class OrdersService {
|
||||
orderNumber: this.generateOrderNumber(),
|
||||
customerId,
|
||||
branchId: dto.branchId,
|
||||
channel: dto.channel ?? OrderChannel.RETAIL,
|
||||
// Customer app orders are wholesale (عمده), matching legacy mobile
|
||||
// setOrder → `orders` table. Admin omde list filters WHOLESALE; the
|
||||
// old RETAIL default hid every app order from that screen.
|
||||
channel:
|
||||
opts.placedBy === 'customer'
|
||||
? OrderChannel.WHOLESALE
|
||||
: (dto.channel ?? OrderChannel.RETAIL),
|
||||
status: initialStatus,
|
||||
totalGoldWeightGrams,
|
||||
wageWeightGrams,
|
||||
|
||||
@@ -113,11 +113,13 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
|
||||
|
||||
.oc__status {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 11px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.oc__figures {
|
||||
|
||||
@@ -15,9 +15,17 @@
|
||||
-->
|
||||
<template>
|
||||
<article class="oi">
|
||||
<div class="oi__art">
|
||||
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
|
||||
<div v-else class="oi__artEmpty" aria-hidden="true">
|
||||
<button
|
||||
v-if="lookup?.imageUrl"
|
||||
type="button"
|
||||
class="oi__art oi__art--preview"
|
||||
:aria-label="`پیشنمایش ${lookup.productTitle ?? 'تصویر'}`"
|
||||
@click="openPreview"
|
||||
>
|
||||
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
|
||||
</button>
|
||||
<div v-else class="oi__art">
|
||||
<div class="oi__artEmpty" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:diamonds" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,11 +51,29 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Above the orders drawer (z-index 2000); teleported so overflow:hidden
|
||||
on the drawer body cannot clip the lightbox. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="oi-preview">
|
||||
<div v-if="previewOpen && lookup?.imageUrl" class="oi__preview" @click.self="closePreview">
|
||||
<button type="button" class="oi__previewClose" aria-label="بستن" @click="closePreview">
|
||||
<Icon icon="vuesax-linear:add" size="20" />
|
||||
</button>
|
||||
<img
|
||||
class="oi__previewImg"
|
||||
:src="lookup.imageUrl"
|
||||
:alt="lookup.productTitle ?? ''"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { orderItemLookup } from '../../composables/useOrderItemProduct'
|
||||
import type { OrderItemDetail } from '../../api/client'
|
||||
@@ -58,6 +84,26 @@ const props = defineProps<{ value: OrderItemDetail }>()
|
||||
// nothing to wait for — the row renders complete on its first frame instead of
|
||||
// filling in after a catalogue download.
|
||||
const lookup = computed(() => orderItemLookup(props.value))
|
||||
|
||||
const previewOpen = ref(false)
|
||||
let lockedBody = false
|
||||
|
||||
function openPreview() {
|
||||
if (!lookup.value?.imageUrl) return
|
||||
previewOpen.value = true
|
||||
if (document.documentElement.style.overflow !== 'hidden') {
|
||||
document.body.style.overflow = 'hidden'
|
||||
lockedBody = true
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewOpen.value = false
|
||||
if (lockedBody) document.body.style.overflow = ''
|
||||
lockedBody = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(closePreview)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -74,16 +120,23 @@ const lookup = computed(() => orderItemLookup(props.value))
|
||||
flex: 0 0 62px;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.oi__art--preview {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.oi__art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Dark mode: product photos are shot on white, so they sit on a light mat
|
||||
@@ -94,6 +147,57 @@ html.theme-dark .oi__art img {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.oi__preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 56px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
backdrop-filter: blur(28px) saturate(140%);
|
||||
}
|
||||
|
||||
.oi__previewClose {
|
||||
position: absolute;
|
||||
top: calc(14px + env(safe-area-inset-top, 0px));
|
||||
inset-inline-end: 14px;
|
||||
z-index: 2;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.oi__previewClose:active {
|
||||
transform: rotate(45deg) scale(0.92);
|
||||
}
|
||||
|
||||
.oi__previewImg {
|
||||
max-width: min(100%, 560px);
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.oi-preview-enter-active,
|
||||
.oi-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.oi-preview-enter-from,
|
||||
.oi-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.oi__artEmpty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!--
|
||||
Shared pull-to-refresh: Vant handles the gesture; content stays put.
|
||||
Full-viewport premium overlay (dark + blur) with a large centered logo.
|
||||
The ring around the logo fills with pull distance, then spins while loading.
|
||||
-->
|
||||
<template>
|
||||
<div class="app-ptr">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="app-ptr__overlay"
|
||||
aria-hidden="true"
|
||||
:class="{ 'is-visible': overlayVisible, 'is-loading': isLoading }"
|
||||
:style="overlayStyle"
|
||||
>
|
||||
<div class="app-ptr__stage" :style="stageStyle">
|
||||
<div class="app-ptr__mark">
|
||||
<svg
|
||||
class="app-ptr__ring keep-motion"
|
||||
:class="{ 'is-spinning': isLoading }"
|
||||
viewBox="0 0 120 120"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="app-ptr__ringTrack" cx="60" cy="60" :r="RING_R" />
|
||||
<circle
|
||||
class="app-ptr__ringFill"
|
||||
cx="60"
|
||||
cy="60"
|
||||
:r="RING_R"
|
||||
:style="ringFillStyle"
|
||||
/>
|
||||
</svg>
|
||||
<img
|
||||
src="/img/brand-logo.png"
|
||||
:alt="brand.name"
|
||||
class="app-ptr__logo"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<PullRefresh
|
||||
class="app-ptr__vant"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:head-height="HEAD_HEIGHT"
|
||||
:pull-distance="PULL_DISTANCE"
|
||||
:success-duration="0"
|
||||
@update:model-value="onModelUpdate"
|
||||
@refresh="emit('refresh')"
|
||||
@change="onChange"
|
||||
>
|
||||
<template #pulling><span /></template>
|
||||
<template #loosing><span /></template>
|
||||
<template #loading><span /></template>
|
||||
<template #success><span /></template>
|
||||
<slot />
|
||||
</PullRefresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { PullRefresh } from 'vant'
|
||||
import { brand } from '../../brand'
|
||||
|
||||
// Longer travel so the ring fill is readable (short pulls used to jump to full).
|
||||
const HEAD_HEIGHT = 100
|
||||
const PULL_DISTANCE = 168
|
||||
const RING_R = 54
|
||||
const RING_C = 2 * Math.PI * RING_R
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
type PtrStatus = 'normal' | 'pulling' | 'loosing' | 'loading' | 'success'
|
||||
|
||||
const distance = ref(0)
|
||||
const status = ref<PtrStatus>('normal')
|
||||
|
||||
function onChange(payload: { status: string; distance: number }) {
|
||||
status.value = payload.status as PtrStatus
|
||||
distance.value = payload.distance
|
||||
}
|
||||
|
||||
function onModelUpdate(value: boolean) {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) {
|
||||
distance.value = 0
|
||||
status.value = 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = computed(() => status.value === 'loading' || props.modelValue)
|
||||
|
||||
/** Linear pull 0→1 over PULL_DISTANCE (overlay / scale use this). */
|
||||
const pullProgress = computed(() => {
|
||||
if (isLoading.value) return 1
|
||||
return Math.min(distance.value / PULL_DISTANCE, 1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Ring fill eased so early pull stays visibly incomplete — linear fill on a
|
||||
* short gesture looked "already full" before the eye caught up.
|
||||
*/
|
||||
const ringProgress = computed(() => {
|
||||
if (isLoading.value) return 1
|
||||
const p = pullProgress.value
|
||||
return Math.pow(p, 1.55)
|
||||
})
|
||||
|
||||
const overlayVisible = computed(() => pullProgress.value > 0.02 || isLoading.value)
|
||||
|
||||
const overlayStyle = computed(() => {
|
||||
const p = pullProgress.value
|
||||
return {
|
||||
opacity: overlayVisible.value ? Math.min(0.18 + p * 0.82, 1) : 0,
|
||||
pointerEvents: (isLoading.value ? 'auto' : 'none') as 'auto' | 'none',
|
||||
}
|
||||
})
|
||||
|
||||
const stageStyle = computed(() => {
|
||||
const p = pullProgress.value
|
||||
const scale = 0.78 + p * 0.22
|
||||
return {
|
||||
transform: `scale(${scale})`,
|
||||
opacity: Math.min(0.45 + p * 0.55, 1),
|
||||
}
|
||||
})
|
||||
|
||||
/** Arc fill follows eased pull; full ring while loading (then CSS spins). */
|
||||
const ringFillStyle = computed(() => {
|
||||
const p = ringProgress.value
|
||||
return {
|
||||
strokeDasharray: `${RING_C}`,
|
||||
strokeDashoffset: `${RING_C * (1 - p)}`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.app-ptr {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.app-ptr__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1900;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(8, 8, 10, 0.58);
|
||||
backdrop-filter: blur(28px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(130%);
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
.app-ptr__overlay:not(.is-visible) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.app-ptr__stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
transition: transform 120ms ease-out, opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.app-ptr__mark {
|
||||
position: relative;
|
||||
width: 152px;
|
||||
height: 152px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.app-ptr__logo {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 14px 32px rgba(0, 0, 0, 0.5));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app-ptr__ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-ptr__ringTrack {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.14);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.app-ptr__ringFill {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.95);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
/* No CSS transition — fill is driven live by finger distance. */
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.app-ptr__ring.is-spinning {
|
||||
animation: app-ptr-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.app-ptr__ring.is-spinning .app-ptr__ringFill {
|
||||
/* Indeterminate feel: short arc chasing while the svg rotates. */
|
||||
stroke-dasharray: 80 260 !important;
|
||||
stroke-dashoffset: 0 !important;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@keyframes app-ptr-spin {
|
||||
to {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Kill Vant's content translate — gesture + distance still update via JS. */
|
||||
.app-ptr__vant {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.app-ptr :deep(.van-pull-refresh__track) {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.app-ptr :deep(.van-pull-refresh__head) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -16,44 +16,40 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
// Ported from legacy order.vue/Orders.vue's setColor() — legacy only ever
|
||||
// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here
|
||||
// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED
|
||||
// stay uncolored, same as legacy's untouched sts=0/2).
|
||||
// Status pill colours for list cards + detail drawer. Every real OrderStatus
|
||||
// gets a tag wash so none render as bare text (SUSPENDED / CANCELLED /
|
||||
// ADMIN_APPROVED used to be uncolored and almost invisible).
|
||||
//
|
||||
// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the
|
||||
// opaque pale tints they used to be: a translucent wash reads the same on a
|
||||
// white card and stays a soft tint on a dark one, where #EFF6FF & co. were
|
||||
// glaring light patches.
|
||||
// Backgrounds use ~18% alpha of the font colour so the pill reads as a tag
|
||||
// on both light and dark canvases.
|
||||
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
|
||||
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
|
||||
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
|
||||
PREPARING: { font: '#F97316', background: '#F973161F' },
|
||||
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
|
||||
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
|
||||
SUSPENDED: { font: '#B45309', background: '#F59E0B2E' },
|
||||
PLACED: { font: '#2563EB', background: '#3B82F62E' },
|
||||
ADMIN_APPROVED: { font: '#4F46E5', background: '#6366F12E' },
|
||||
SOURCING: { font: '#A16207', background: '#EAB3082E' },
|
||||
PREPARING: { font: '#C2410C', background: '#F973162E' },
|
||||
DELIVERED_TO_AGENT: { font: '#0E7490', background: '#06B6D42E' },
|
||||
DELIVERED_TO_CUSTOMER: { font: '#15803D', background: '#22C55E2E' },
|
||||
CANCELLED: { font: '#B91C1C', background: '#EF44442E' },
|
||||
};
|
||||
|
||||
// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`).
|
||||
// Legacy's ambiguous sts=0 (suspended vs cancelled) is resolved here since our
|
||||
// enum keeps them distinct; SUSPENDED is grouped with the first step (not yet
|
||||
// progressing), matching the spirit of legacy's grouping of early statuses.
|
||||
export const ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده'];
|
||||
/**
|
||||
* Linear pipeline shown in the order-detail tracker — same order as the admin
|
||||
* STATUS_FLOW / Prisma OrderStatus happy path. SUSPENDED and CANCELLED sit
|
||||
* outside this pipeline (badge only; step index is -1).
|
||||
*/
|
||||
export const ORDER_FLOW_STATUSES = [
|
||||
'PLACED',
|
||||
'ADMIN_APPROVED',
|
||||
'SOURCING',
|
||||
'PREPARING',
|
||||
'DELIVERED_TO_AGENT',
|
||||
'DELIVERED_TO_CUSTOMER',
|
||||
] as const;
|
||||
|
||||
export const ORDER_STEPS = ORDER_FLOW_STATUSES.map((status) => ORDER_STATUS_LABELS[status]);
|
||||
|
||||
/** Index into ORDER_STEPS, or -1 when the status is not on the pipeline. */
|
||||
export function orderStepIndex(status: string): number {
|
||||
switch (status) {
|
||||
case 'PLACED':
|
||||
case 'ADMIN_APPROVED':
|
||||
case 'SUSPENDED':
|
||||
return 0;
|
||||
case 'SOURCING':
|
||||
case 'PREPARING':
|
||||
return 1;
|
||||
case 'DELIVERED_TO_AGENT':
|
||||
case 'DELIVERED_TO_CUSTOMER':
|
||||
return 2;
|
||||
case 'CANCELLED':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
return (ORDER_FLOW_STATUSES as readonly string[]).indexOf(status);
|
||||
}
|
||||
|
||||
@@ -32,11 +32,7 @@
|
||||
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
|
||||
<container class="cart__body">
|
||||
<!-- Step 1: the basket -->
|
||||
<PullRefresh v-if="cartData.step === 1" 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>
|
||||
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
|
||||
<template v-if="cart.items.length">
|
||||
<div class="cart__items" :class="{ 'is-revealed': revealed }">
|
||||
<CartItem
|
||||
@@ -62,7 +58,7 @@
|
||||
دیدن محصولات
|
||||
</button>
|
||||
</section>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
|
||||
<!-- Step 2: submitted -->
|
||||
<section v-else class="cart__done">
|
||||
@@ -153,7 +149,7 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
|
||||
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
|
||||
import loginDialogImage from '../assets/img/empty/dialogLogin.svg'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
@@ -218,6 +214,9 @@ const submitOrder = async () => {
|
||||
// Ignored by the server, which takes the customer from the token; still
|
||||
// sent so older builds keep validating. Removed in Phase 2.
|
||||
customerId: session.customerId ?? undefined,
|
||||
// Wholesale (عمده) — same as legacy mobile; server also forces this for
|
||||
// customer placements so admin omde list shows the order.
|
||||
channel: 'WHOLESALE',
|
||||
items: cart.items.map((item) => {
|
||||
const variant = cart.variant(item)
|
||||
return {
|
||||
|
||||
@@ -34,11 +34,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="home__body" :class="{ 'is-revealed': revealed }">
|
||||
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
|
||||
|
||||
@@ -103,7 +99,7 @@
|
||||
</section>
|
||||
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,7 +107,7 @@
|
||||
import { brand } from '../brand'
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
import RateStrip from '../components/home/RateStrip.vue'
|
||||
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
|
||||
|
||||
@@ -36,11 +36,7 @@
|
||||
</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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="cho__body">
|
||||
<template v-if="rows.length">
|
||||
<div class="cho__list">
|
||||
@@ -99,7 +95,7 @@
|
||||
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
|
||||
<div ref="sentinel" class="cho__sentinel" />
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
@@ -146,7 +142,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
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'
|
||||
|
||||
@@ -40,11 +40,7 @@
|
||||
</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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="cch__body">
|
||||
<div v-if="rows.length" class="cch__list">
|
||||
<article
|
||||
@@ -88,7 +84,7 @@
|
||||
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
|
||||
</section>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
@@ -143,7 +139,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
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'
|
||||
|
||||
@@ -52,11 +52,7 @@
|
||||
</PageBar>
|
||||
|
||||
<Spinner :spinning="loading && !rows.length && !details.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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="rep__body">
|
||||
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
|
||||
<template v-if="activeKey === '1'">
|
||||
@@ -124,7 +120,7 @@
|
||||
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
|
||||
<div ref="sentinel" class="rep__sentinel" />
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
@@ -151,7 +147,7 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
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'
|
||||
|
||||
@@ -61,11 +61,7 @@
|
||||
indicator, over the results already on screen.
|
||||
-->
|
||||
<div>
|
||||
<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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="search__body">
|
||||
<div v-if="loadingPage && !allProducts.length" class="search__skeleton" aria-hidden="true">
|
||||
<div class="search__skHead">
|
||||
@@ -117,7 +113,7 @@
|
||||
<p>محصولی با این مشخصات پیدا نشد.</p>
|
||||
</section>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</div>
|
||||
|
||||
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
|
||||
@@ -133,7 +129,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
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'
|
||||
|
||||
@@ -48,15 +48,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="dash__body" :class="{ 'is-revealed': revealed }">
|
||||
<!-- Signed in: real position from GET /customers/:id -->
|
||||
<template v-if="customer">
|
||||
<section class="dash__balance reveal" :style="revealDelay(0)">
|
||||
<!--
|
||||
Incomplete profile notice — same gate as ProfileView's
|
||||
"تکمیل پروفایل" button (docs/plans/2026-10-16-dashboard-incomplete-profile-banner-design.md).
|
||||
-->
|
||||
<section
|
||||
v-if="needsProfileComplete"
|
||||
class="dash__profileNudge reveal"
|
||||
:style="revealDelay(0)"
|
||||
>
|
||||
<div class="dash__profileNudgeIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:info-circle" size="16" />
|
||||
</div>
|
||||
<div class="dash__profileNudgeCopy">
|
||||
<h3>پروفایل شما تکمیل نشده است</h3>
|
||||
<p>برای ادامه، مدارک پروفایل را بارگذاری کنید.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="dash__profileNudgeCta"
|
||||
@click="router.push({ name: 'completeProfile' })"
|
||||
>
|
||||
تکمیل پروفایل
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="dash__balance reveal" :style="revealDelay(needsProfileComplete ? 1 : 0)">
|
||||
<div class="dash__balanceHead">
|
||||
<div>
|
||||
<div class="dash__eyebrow">{{ remainText }}</div>
|
||||
@@ -97,7 +118,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Nav grid -->
|
||||
<section class="dash__section reveal" :style="revealDelay(1)">
|
||||
<section class="dash__section reveal" :style="revealDelay(customer && needsProfileComplete ? 2 : 1)">
|
||||
<div class="dash__access">
|
||||
<button
|
||||
v-for="item in list"
|
||||
@@ -119,12 +140,12 @@
|
||||
</section>
|
||||
|
||||
<!-- Live 18k rate — same strip the home screen uses -->
|
||||
<section class="dash__section reveal" :style="revealDelay(2)">
|
||||
<section class="dash__section reveal" :style="revealDelay(customer && needsProfileComplete ? 3 : 2)">
|
||||
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلامشده" />
|
||||
<RateStrip :quote="goldPrice" />
|
||||
</section>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
@@ -159,7 +180,7 @@
|
||||
import { brand } from '../../brand'
|
||||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import RateStrip from '../../components/home/RateStrip.vue'
|
||||
@@ -190,6 +211,15 @@ const grams = computed(() =>
|
||||
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Same gate as ProfileView's "تکمیل پروفایل" CTA: show while documents have
|
||||
* not been submitted (STEP_TWO_SUBMITTED) or approved (VERIFIED).
|
||||
*/
|
||||
const needsProfileComplete = computed(() => {
|
||||
const step = customer.value?.verificationStep
|
||||
return Boolean(step) && step !== 'VERIFIED' && step !== 'STEP_TWO_SUBMITTED'
|
||||
})
|
||||
|
||||
const data = reactive({ notify: false, loginNotify: false })
|
||||
|
||||
const BASE_TILES = [
|
||||
@@ -370,6 +400,68 @@ onMounted(async () => {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.dash__profileNudge {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid alpha(@wine, 16%);
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.dash__profileNudgeIcon {
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: alpha(@wine, 10%);
|
||||
}
|
||||
|
||||
.dash__profileNudgeCopy {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash__profileNudgeCopy h3 {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: @ink;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dash__profileNudgeCopy p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.dash__profileNudgeCta {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
padding: 7px 11px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: @primary;
|
||||
}
|
||||
|
||||
.dash__profileNudgeCta:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.dash__balance {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
|
||||
@@ -48,11 +48,7 @@
|
||||
</PageBar>
|
||||
|
||||
<Spinner :spinning="loading" 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>
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="ord__body">
|
||||
<template v-if="filteredOrders.length">
|
||||
<div class="ord__list">
|
||||
@@ -65,7 +61,7 @@
|
||||
<p>سفارشی با این مشخصات پیدا نشد.</p>
|
||||
</section>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
@@ -73,22 +69,22 @@
|
||||
placement="bottom"
|
||||
v-if="viewData.item"
|
||||
:closable="false"
|
||||
height="100vh"
|
||||
class="rtl"
|
||||
:bodyStyle="{ padding: 0, position: 'relative' }"
|
||||
height="100%"
|
||||
:z-index="2000"
|
||||
class="rtl orders-detail-drawer"
|
||||
:bodyStyle="{ padding: 0, height: '100%', overflow: 'hidden' }"
|
||||
>
|
||||
<div class="detail">
|
||||
<header class="detail__bar">
|
||||
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
|
||||
<button type="button" class="detail__close" aria-label="بستن" @click="closeDetail">
|
||||
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
|
||||
</button>
|
||||
<h2 class="detail__title">جزئیات سفارش</h2>
|
||||
<span class="detail__spacer" aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<div class="detail__scroll">
|
||||
<!-- Progress -->
|
||||
<section class="detail__block">
|
||||
<!-- Progress — real OrderStatus pipeline (not the old 4-step collapse) -->
|
||||
<section v-if="currentStep >= 0" class="detail__block">
|
||||
<div class="detail__steps">
|
||||
<div
|
||||
v-for="(step, index) in ORDER_STEPS"
|
||||
@@ -147,9 +143,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
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'
|
||||
@@ -164,11 +160,13 @@ import {
|
||||
} from '../../utils/orderStatus'
|
||||
import { jdate } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { useNavStore } from '../../stores/navStore'
|
||||
import type { OrderDetail } from '../../api/client'
|
||||
import { useCachedReads } from '../../api/cachedReads'
|
||||
import { KEYS } from '../../stores/queryCache'
|
||||
|
||||
const session = useSessionStore()
|
||||
const nav = useNavStore()
|
||||
const reads = useCachedReads()
|
||||
|
||||
const tabs = [
|
||||
@@ -227,6 +225,23 @@ function openDetail(item: OrderDetail) {
|
||||
viewData.model = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
viewData.model = false
|
||||
}
|
||||
|
||||
// Full-screen detail must own the viewport: hide the tab bar so it cannot
|
||||
// cover the sheet, and restore it when the drawer closes (or this view leaves).
|
||||
watch(
|
||||
() => viewData.model,
|
||||
(open) => {
|
||||
nav.setNavVisibility(!open)
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!nav.showNav) nav.setNavVisibility(true)
|
||||
})
|
||||
|
||||
async function onRefresh() {
|
||||
await loadOrders(true)
|
||||
refreshing.value = false
|
||||
@@ -339,32 +354,44 @@ onActivated(() => {
|
||||
|
||||
/* ── Detail drawer ─────────────────────────────────────────────────── */
|
||||
.detail {
|
||||
height: 100vh;
|
||||
// Fill the drawer body — never 100vh: on mobile browsers that unit is
|
||||
// taller than the visible viewport, so a bottom sheet's header sat above
|
||||
// the screen and the close control was unreachable.
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: @canvas;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail__bar {
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 16px;
|
||||
padding: calc(11px + env(safe-area-inset-top, 0px)) 16px 11px;
|
||||
border-bottom: 1px solid @hairline;
|
||||
background: alpha(@canvas, 92%);
|
||||
background: alpha(@canvas, 96%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
text-align: center;
|
||||
/* Balance the close button so the title sits visually centered. */
|
||||
padding-inline-end: 38px;
|
||||
}
|
||||
|
||||
.detail__close {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
@@ -376,17 +403,17 @@ onActivated(() => {
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.detail__spacer {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
.detail__close:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.detail__scroll {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 16px;
|
||||
padding-bottom: 40px;
|
||||
padding-bottom: calc(40px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.detail__block {
|
||||
@@ -476,11 +503,13 @@ onActivated(() => {
|
||||
|
||||
.detail__status {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 12px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.detail__specs {
|
||||
@@ -522,3 +551,22 @@ onActivated(() => {
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Drawer teleports to <body> — viewport lock must live outside scoped CSS. -->
|
||||
<style lang="less">
|
||||
.orders-detail-drawer.ant-drawer-bottom .ant-drawer-content-wrapper {
|
||||
// Prefer the dynamic viewport so the sheet never taller than what the user
|
||||
// can see (classic 100vh / mobile URL-bar bug that hid the close bar).
|
||||
height: 100% !important;
|
||||
max-height: 100vh !important;
|
||||
max-height: 100dvh !important;
|
||||
}
|
||||
|
||||
.orders-detail-drawer .ant-drawer-content,
|
||||
.orders-detail-drawer .ant-drawer-wrapper-body,
|
||||
.orders-detail-drawer .ant-drawer-body {
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Dashboard — incomplete profile banner
|
||||
|
||||
**Status:** approved
|
||||
**Date:** 2026-10-16
|
||||
**Scope:** `apps/user-app` `DashboardView.vue`
|
||||
|
||||
## Intent
|
||||
|
||||
When a signed-in customer has not finished profile verification documents, show a fixed notice on the workbench (میز کار) above the balance card, with a CTA to complete profile.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Choice | Decision |
|
||||
|--------|----------|
|
||||
| Placement | Above balance card inside `dash__body` |
|
||||
| Form | Fixed banner + CTA (not dismissible toast) |
|
||||
| Visibility | Same gate as ProfileView `canCompleteProfile`: step set and not `VERIFIED` / `STEP_TWO_SUBMITTED` |
|
||||
| CTA | `router.push({ name: 'completeProfile' })` |
|
||||
| Out of scope | Home banner, tile badge, new API |
|
||||
|
||||
## Copy
|
||||
|
||||
- Title/body: پروفایل شما تکمیل نشده است
|
||||
- CTA: تکمیل پروفایل
|
||||
+5
@@ -62,6 +62,11 @@ export interface CreateOrderDto {
|
||||
*/
|
||||
customerId?: string;
|
||||
branchId?: string;
|
||||
/**
|
||||
* Sales channel. Customer-app orders are forced to WHOLESALE on the server
|
||||
* (legacy mobile parity). Optional for type clarity on the client payload.
|
||||
*/
|
||||
channel?: 'RETAIL' | 'WHOLESALE';
|
||||
items: CreateOrderItemDto[];
|
||||
}
|
||||
export interface LedgerEntryDto {
|
||||
|
||||
@@ -74,6 +74,11 @@ export interface CreateOrderDto {
|
||||
*/
|
||||
customerId?: string;
|
||||
branchId?: string;
|
||||
/**
|
||||
* Sales channel. Customer-app orders are forced to WHOLESALE on the server
|
||||
* (legacy mobile parity). Optional for type clarity on the client payload.
|
||||
*/
|
||||
channel?: 'RETAIL' | 'WHOLESALE';
|
||||
items: CreateOrderItemDto[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user