feat: order revision, rial wage from variant, product tags; drop user-app-v2
Admin order edit with category chips and timeline; customer checkout charges variant rialWage; product tags; retire the unused user-app-v2 tree. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -89,6 +89,7 @@ const EXACT: Record<string, string> = {
|
||||
// ── products (Batch 6) ────────────────────────────────────────────
|
||||
products: 'admin/products/list',
|
||||
'product/create': 'admin/products/create',
|
||||
'product/categories': 'admin/products/categories',
|
||||
'get/variants': 'admin/products/variants/list',
|
||||
'change/percent/count': 'admin/products/change-percent/count',
|
||||
'change/all': 'admin/products/change-percent/apply',
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Mirrors apps/user-app/src/utils/orderStatus.ts — same labels, blurbs and
|
||||
// colors so the admin status modal feels like the customer timeline sheet.
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
CarOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
FileDoneOutlined,
|
||||
GiftOutlined,
|
||||
InboxOutlined,
|
||||
PauseCircleOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
SUSPENDED: 'معلق',
|
||||
PLACED: 'ثبت سفارش',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_BLURBS: Record<string, string> = {
|
||||
SUSPENDED: 'سفارش بهدلیل سقف اعتبار معلق شد',
|
||||
PLACED: 'سفارش ثبت و دریافت شد',
|
||||
ADMIN_APPROVED: 'سفارش توسط مدیر تایید شد',
|
||||
SOURCING: 'کالا در حال تامین است',
|
||||
PREPARING: 'سفارش در حال آمادهسازی است',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده فروش انجام شد',
|
||||
DELIVERED_TO_CUSTOMER: 'سفارش به مشتری تحویل شد',
|
||||
CANCELLED: 'سفارش لغو شده است',
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
|
||||
SUSPENDED: { font: '#A16207', background: '#A162071F' },
|
||||
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
|
||||
ADMIN_APPROVED: { font: '#6366F1', background: '#6366F11F' },
|
||||
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
|
||||
PREPARING: { font: '#F97316', background: '#F973161F' },
|
||||
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
|
||||
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
|
||||
CANCELLED: { font: '#DC2626', background: '#DC26261F' },
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_ICONS: Record<string, Component> = {
|
||||
SUSPENDED: PauseCircleOutlined,
|
||||
PLACED: FileDoneOutlined,
|
||||
ADMIN_APPROVED: SafetyCertificateOutlined,
|
||||
SOURCING: InboxOutlined,
|
||||
PREPARING: GiftOutlined,
|
||||
DELIVERED_TO_AGENT: CarOutlined,
|
||||
DELIVERED_TO_CUSTOMER: CheckCircleOutlined,
|
||||
CANCELLED: CloseCircleOutlined,
|
||||
};
|
||||
|
||||
const FALLBACK_COLOR = { font: '#64748B', background: '#64748B1F' };
|
||||
|
||||
export function orderStatusColor(status: string) {
|
||||
return ORDER_STATUS_COLORS[status] ?? FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
export function orderStatusLabel(status: string) {
|
||||
return ORDER_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
export function orderStatusBlurb(status: string) {
|
||||
return ORDER_STATUS_BLURBS[status] ?? '';
|
||||
}
|
||||
|
||||
export function orderStatusIcon(status: string) {
|
||||
return ORDER_STATUS_ICONS[status] ?? FileDoneOutlined;
|
||||
}
|
||||
@@ -9,8 +9,9 @@
|
||||
|
||||
Fields with no column in the redesigned schema are NOT rendered rather than
|
||||
collected and silently dropped — listed in docs/ADMIN-PORT-PLAN.md:
|
||||
delivery_type, message_long_delivery, offer_day, has_warranty, is_exist,
|
||||
tags, and the variant colour/rial-wage fields.
|
||||
delivery_type, message_long_delivery, offer_day, has_warranty, is_exist.
|
||||
Tags are wired again: chosen from Setting `__list__tags` and stored on
|
||||
Product.tags; the storefront shows them on list cards only.
|
||||
|
||||
The image uploaders ARE rendered (unlike the note above once claimed):
|
||||
Product.images is a real ProductImage[] relation and `file/create` is a real,
|
||||
@@ -256,7 +257,7 @@
|
||||
<span class="pf-section__icon"><TagsOutlined /></span>
|
||||
<div>
|
||||
<h3>دستهبندی</h3>
|
||||
<p>دسته، برند و سینیهای محصول</p>
|
||||
<p>دسته، برند، سینی و برچسبهای محصول</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,7 +269,7 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="برند" :class="{ 'mb-0': !users.isSuperAdmin }">
|
||||
<a-form-item label="برند">
|
||||
<a-select v-model:value="form.brand_id" allow-clear show-search option-filter-prop="label" placeholder="انتخاب کنید">
|
||||
<a-select-option v-for="b in brands" :key="b.id" :value="b.id" :label="b.title">
|
||||
{{ b.title }}
|
||||
@@ -277,7 +278,7 @@
|
||||
</a-form-item>
|
||||
|
||||
<!-- legacy "سینی ها" — the Collection grouping -->
|
||||
<a-form-item v-if="users.isSuperAdmin" label="سینی ها" class="mb-0">
|
||||
<a-form-item v-if="users.isSuperAdmin" label="سینی ها">
|
||||
<a-select
|
||||
v-model:value="form.ciny_ids"
|
||||
mode="multiple"
|
||||
@@ -290,6 +291,17 @@
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="برچسب ها" class="mb-0">
|
||||
<a-select
|
||||
v-model:value="form.tags"
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
option-filter-prop="label"
|
||||
placeholder="انتخاب از فهرست برچسبها"
|
||||
:options="tagOptions.map((t) => ({ value: t, label: t }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
@@ -529,6 +541,7 @@ const activeGalleryTab = ref<'site' | 'mobile'>('site');
|
||||
const brands = ref<{ id: string; title: string }[]>([]);
|
||||
const categories = ref<{ id: string; title: string }[]>([]);
|
||||
const collections = ref<{ id: string; title: string }[]>([]);
|
||||
const tagOptions = ref<string[]>([]);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
title: '',
|
||||
@@ -537,6 +550,7 @@ const form = reactive<Record<string, any>>({
|
||||
category_id: undefined,
|
||||
brand_id: undefined,
|
||||
ciny_ids: [] as string[],
|
||||
tags: [] as string[],
|
||||
body_html: '',
|
||||
is_active: true,
|
||||
indicator_id: null as string | null,
|
||||
@@ -715,11 +729,12 @@ async function handleSubmit() {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [b, c, col, colorsRes] = await Promise.all([
|
||||
const [b, c, col, colorsRes, tagsRes] = await Promise.all([
|
||||
api.get('brand'),
|
||||
api.post('category'),
|
||||
users.isSuperAdmin ? api.get('admin/products/collections') : Promise.resolve({ data: [] }),
|
||||
api.get('admin/products/colors').catch(() => ({ data: [] })),
|
||||
api.get('const/__list__tags').catch(() => ({ data: [] })),
|
||||
]);
|
||||
brands.value = b.data;
|
||||
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
|
||||
@@ -729,6 +744,7 @@ onMounted(async () => {
|
||||
}));
|
||||
collections.value = col.data;
|
||||
colorOptions.value = colorsRes.data ?? [];
|
||||
tagOptions.value = Array.isArray(tagsRes.data) ? tagsRes.data.map(String) : [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -746,6 +762,7 @@ onMounted(async () => {
|
||||
category_id: data.category?.id ?? undefined,
|
||||
brand_id: data.brand?.id ?? undefined,
|
||||
ciny_ids: data.ciny_ids ?? [],
|
||||
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||
body_html: data.body_html ?? '',
|
||||
is_active: data.is_active ?? true,
|
||||
indicator_id: data.indicator_id ?? null,
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #tags="{ record }">
|
||||
<div v-if="record.tags?.length" class="product-tags">
|
||||
<a-tag v-for="tag in record.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</div>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template #show_type="{ record }">
|
||||
{{ record.show_type_str }}
|
||||
</template>
|
||||
@@ -82,6 +89,12 @@
|
||||
<a-descriptions-item label="دسته بندی">
|
||||
{{ tempRow.category?.title || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="برچسبها">
|
||||
<template v-if="tempRow.tags?.length">
|
||||
<a-tag v-for="tag in tempRow.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="users.isSuperAdmin" label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
|
||||
<a-descriptions-item label="تعداد تنوع">
|
||||
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
|
||||
@@ -184,6 +197,16 @@ function toEdit(id: string) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
/* the actions sit in the card header on phones; a fixed 12rem would crowd the title */
|
||||
.w-48 {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function buildProductListModel(withShowType = true): XTbl {
|
||||
// legacy key is 'category.collection.title'; the mapping layer aliases it
|
||||
tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText());
|
||||
tbl.add(new Xtc('product_code', 'کد محصول').filterText());
|
||||
tbl.add(new Xtc('tags', 'برچسبها').renderSlot('tags').noSort());
|
||||
if (withShowType) {
|
||||
tbl.add(
|
||||
new Xtc('show_type', 'نوع نمایش')
|
||||
|
||||
@@ -35,7 +35,7 @@ export function buildOmdeListModel(source: OmdeSourceKey): XTbl {
|
||||
tbl.add(new Xtc('fname', 'نام').filterText().hidden());
|
||||
tbl.add(new Xtc('lname', 'نام خانوادگی').filterText().hidden());
|
||||
tbl.add(new Xtc('name', 'نام و نام خانوادگی').filterText());
|
||||
tbl.add(new Xtc('mobile', 'موبایل').filterText().hidden().noSort());
|
||||
tbl.add(new Xtc('mobile', 'موبایل').filterText().noSort());
|
||||
tbl.add(
|
||||
new Xtc('city_id', 'شهر')
|
||||
.hidden()
|
||||
|
||||
@@ -18,7 +18,7 @@ export function buildReagentListModel(): XTbl {
|
||||
tbl.add(new Xtc('name', 'نام و نام خانوادگی').filterText());
|
||||
// legacy adds 'name' a second time, hidden — preserved
|
||||
tbl.add(new Xtc('name', 'نام').filterText().hidden());
|
||||
tbl.add(new Xtc('mobile', 'موبایل').filterText().hidden().noSort());
|
||||
tbl.add(new Xtc('mobile', 'موبایل').filterText().noSort());
|
||||
tbl.add(
|
||||
new Xtc('city_id', 'شهر')
|
||||
.hidden()
|
||||
|
||||
@@ -51,103 +51,60 @@
|
||||
</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>
|
||||
<a-modal
|
||||
v-model:open="visibleSts"
|
||||
title="وضعیت سفارش"
|
||||
width="480px"
|
||||
wrap-class-name="detail-modal"
|
||||
>
|
||||
<OrderStatusTimeline
|
||||
v-if="tempRow"
|
||||
:activity="tempRow.activity || []"
|
||||
:can-submit="hasPermission('order.list.sts.submit')"
|
||||
:can-remove="hasPermission('order.list.sts.remove')"
|
||||
@submit="setSts"
|
||||
@remove="removeSts"
|
||||
/>
|
||||
<template #footer>
|
||||
<a-button @click="visibleSts = false">باشه</a-button>
|
||||
<a-button @click="visibleSts = false">بستن</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- مشاهده سفارش -->
|
||||
<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 />
|
||||
<!-- مشاهده / ویرایش سفارش -->
|
||||
<a-modal
|
||||
v-model:open="visibleList"
|
||||
title="مشاهده سفارش"
|
||||
:width="editMode && canEditOrder ? 1120 : 760"
|
||||
wrap-class-name="detail-modal order-detail-modal"
|
||||
:body-style="{ maxHeight: '78vh', overflowY: 'auto' }"
|
||||
>
|
||||
<OrderDetailBody
|
||||
:order="tempRow"
|
||||
:edit-mode="editMode && canEditOrder"
|
||||
v-model:add-variant-id="addVariantId"
|
||||
v-model:add-qty="addQty"
|
||||
@remove-line="removeLine"
|
||||
@add-line="onAddLine"
|
||||
/>
|
||||
|
||||
<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 class="flex justify-between flex-wrap gap-2">
|
||||
<a-button @click="closeDetail">بستن</a-button>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<template v-if="tempRow?.revisionStatus === 'AWAITING_CUSTOMER' && canEditOrder">
|
||||
<a-button @click="discardEdit">لغو پیشنهاد</a-button>
|
||||
<a-button type="primary" danger @click="forceApplyEdit">اعمال اجباری</a-button>
|
||||
</template>
|
||||
<template v-else-if="canEditOrder">
|
||||
<a-button v-if="!editMode" type="primary" @click="editMode = true">شروع ویرایش</a-button>
|
||||
<template v-else>
|
||||
<a-button @click="cancelEdit">انصراف</a-button>
|
||||
<a-button type="primary" danger :loading="saving" @click="submitEdit">
|
||||
ارسال برای تأیید مشتری
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
@@ -160,11 +117,13 @@ import { computed, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
|
||||
import OrderDetailBody from './OrderDetailBody.vue';
|
||||
import OrderStatusTimeline from './OrderStatusTimeline.vue';
|
||||
import { buildOrderListModel } from './orderListModel';
|
||||
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));
|
||||
@@ -173,17 +132,16 @@ const model = buildOrderListModel();
|
||||
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
|
||||
const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const detailSnapshot = ref<string>('');
|
||||
const visibleList = ref(false);
|
||||
const visibleSts = ref(false);
|
||||
const editMode = ref(false);
|
||||
const saving = ref(false);
|
||||
const addVariantId = ref('');
|
||||
const addQty = ref(1);
|
||||
|
||||
const is_branch = computed(() => isBranch());
|
||||
|
||||
const formatDate = (v: string | null) => (v ? jdate(v) : '');
|
||||
|
||||
function getColorSts(row: { proceed?: boolean }) {
|
||||
return row.proceed ? 'green' : 'gray';
|
||||
}
|
||||
const canEditOrder = computed(() => hasPermission('order.list.view.edit'));
|
||||
|
||||
async function showSts(row: Record<string, any>) {
|
||||
// legacy: branch orders have no editable status timeline
|
||||
@@ -200,12 +158,8 @@ async function loadDetail(id: string) {
|
||||
|
||||
/**
|
||||
* Both handlers receive one ACTIVITY entry from the timeline
|
||||
* (`{status, title, proceed, uaj, note}` — admin-orders.service.ts:249-258),
|
||||
* which carries no order id. Posting that object straight through left
|
||||
* `orderId` missing and UpdateOrderStatusDto rejected every call with 400, so
|
||||
* neither "ثبت وضعیت" nor "حذف وضعیت" worked on this screen at all. The id
|
||||
* comes from `tempRow`, the order loaded when the dialog opened. OmdeList.vue
|
||||
* already built its body this way (OmdeList.vue:187).
|
||||
* (`{status, title, proceed, uaj, note}`), which carries no order id.
|
||||
* The id comes from `tempRow`, the order loaded when the dialog opened.
|
||||
*/
|
||||
async function setSts(row: Record<string, any>) {
|
||||
try {
|
||||
@@ -238,21 +192,122 @@ async function removeSts(row: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function editRow() {
|
||||
function closeDetail() {
|
||||
visibleList.value = false;
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (detailSnapshot.value) {
|
||||
try {
|
||||
tempRow.value = JSON.parse(detailSnapshot.value);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
}
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
if (!tempRow.value?.items) return;
|
||||
if (tempRow.value.items.length <= 1) {
|
||||
message.warning('حداقل یک آیتم باید در سفارش بماند');
|
||||
return;
|
||||
}
|
||||
tempRow.value.items.splice(index, 1);
|
||||
}
|
||||
|
||||
function onAddLine(payload: {
|
||||
variantId: string;
|
||||
qty: number;
|
||||
title?: string;
|
||||
variantTitle?: string;
|
||||
grams?: number;
|
||||
wagePercent?: number;
|
||||
flatRialCharge?: number;
|
||||
image?: string | null;
|
||||
}) {
|
||||
const vid = payload.variantId.trim();
|
||||
if (!vid) {
|
||||
message.warning('یک تنوع انتخاب کنید');
|
||||
return;
|
||||
}
|
||||
if (!tempRow.value) return;
|
||||
tempRow.value.items.push({
|
||||
productVariantId: vid,
|
||||
title: payload.title ?? 'آیتم جدید',
|
||||
variant_title: payload.variantTitle ?? vid,
|
||||
qty: payload.qty || 1,
|
||||
grams: payload.grams ?? 0,
|
||||
flatRialCharge: payload.flatRialCharge ?? 0,
|
||||
wagePercent: payload.wagePercent,
|
||||
rash: payload.grams ?? 0,
|
||||
image: payload.image ?? null,
|
||||
});
|
||||
addVariantId.value = '';
|
||||
addQty.value = 1;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!tempRow.value?.id) return;
|
||||
const items = (tempRow.value.items ?? [])
|
||||
.filter((it: any) => it.productVariantId)
|
||||
.map((it: any) => ({
|
||||
productVariantId: it.productVariantId,
|
||||
quantity: Number(it.qty) || 1,
|
||||
grams: Number(it.grams) || undefined,
|
||||
flatRialCharge: Number(it.flatRialCharge) || 0,
|
||||
rashWeightGrams: it.rash != null ? Number(it.rash) : undefined,
|
||||
wagePercent: it.wagePercent != null ? Number(it.wagePercent) : undefined,
|
||||
discountPercent: it.discountPercent != null ? Number(it.discountPercent) : undefined,
|
||||
}));
|
||||
if (!items.length) {
|
||||
message.error('حداقل یک آیتم لازم است');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
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('ویرایش موفق آمیز');
|
||||
await api.post('admin/orders/edit/propose', { orderId: tempRow.value.id, items });
|
||||
message.success('پیشنهاد ویرایش ثبت شد؛ پیامک برای مشتری ارسال میشود');
|
||||
editMode.value = false;
|
||||
tempRow.value = await loadDetail(tempRow.value.id);
|
||||
detailSnapshot.value = JSON.stringify(tempRow.value);
|
||||
tbl.value?.fetch();
|
||||
} catch (e) {
|
||||
message.error('خطا در ویرایش');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت ویرایش');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function forceApplyEdit() {
|
||||
if (!tempRow.value?.id) return;
|
||||
try {
|
||||
await api.post('admin/orders/edit/force-apply', { orderId: tempRow.value.id });
|
||||
message.success('ویرایش بهصورت اجباری اعمال شد');
|
||||
tempRow.value = await loadDetail(tempRow.value.id);
|
||||
tbl.value?.fetch();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در اعمال');
|
||||
}
|
||||
}
|
||||
|
||||
async function discardEdit() {
|
||||
if (!tempRow.value?.id) return;
|
||||
try {
|
||||
await api.post('admin/orders/edit/discard', { orderId: tempRow.value.id });
|
||||
message.success('پیشنهاد ویرایش لغو شد');
|
||||
tempRow.value = await loadDetail(tempRow.value.id);
|
||||
tbl.value?.fetch();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در لغو');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewOrder(row: Record<string, any>) {
|
||||
tempRow.value = await loadDetail(row.id);
|
||||
detailSnapshot.value = JSON.stringify(tempRow.value);
|
||||
editMode.value = false;
|
||||
visibleList.value = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,81 +55,61 @@
|
||||
</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>
|
||||
<a-modal
|
||||
v-model:open="visibleSts"
|
||||
title="وضعیت سفارش"
|
||||
width="480px"
|
||||
wrap-class-name="detail-modal"
|
||||
>
|
||||
<OrderStatusTimeline
|
||||
v-if="tempRow"
|
||||
:activity="tempRow.activity || []"
|
||||
:can-submit="hasPermission('order.omde.state.submit')"
|
||||
:can-remove="hasPermission('order.omde.state.remove')"
|
||||
@submit="setSts"
|
||||
@remove="removeSts"
|
||||
/>
|
||||
<template #footer>
|
||||
<a-button @click="visibleSts = false">باشه</a-button>
|
||||
<a-button @click="visibleSts = false">بستن</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- مشاهده سفارش -->
|
||||
<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 />
|
||||
<!-- مشاهده / ویرایش سفارش (عمده — همان فلو revision اپ کاربر) -->
|
||||
<a-modal
|
||||
v-model:open="visibleList"
|
||||
title="مشاهده سفارش"
|
||||
:width="editMode && canEditOrder ? 1120 : 760"
|
||||
wrap-class-name="detail-modal order-detail-modal"
|
||||
:body-style="{ maxHeight: '78vh', overflowY: 'auto' }"
|
||||
>
|
||||
<OrderDetailBody
|
||||
:order="tempRow"
|
||||
:edit-mode="editMode && canEditOrder"
|
||||
v-model:add-variant-id="addVariantId"
|
||||
v-model:add-qty="addQty"
|
||||
@remove-line="removeLine"
|
||||
@add-line="onAddLine"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="visibleList = false">باشه</a-button>
|
||||
<div class="flex justify-between flex-wrap gap-2">
|
||||
<a-button @click="closeDetail">بستن</a-button>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<template v-if="tempRow?.revisionStatus === 'AWAITING_CUSTOMER' && canEditOrder">
|
||||
<a-button @click="discardEdit">لغو پیشنهاد</a-button>
|
||||
<a-button type="primary" danger @click="forceApplyEdit">اعمال اجباری</a-button>
|
||||
</template>
|
||||
<template v-else-if="canEditOrder">
|
||||
<a-button v-if="!editMode" type="primary" @click="editMode = true">شروع ویرایش</a-button>
|
||||
<template v-else>
|
||||
<a-button @click="cancelEdit">انصراف</a-button>
|
||||
<a-button type="primary" danger :loading="saving" @click="submitEdit">
|
||||
ارسال برای تأیید مشتری
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
@@ -137,14 +117,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
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 OrderDetailBody from './OrderDetailBody.vue';
|
||||
import OrderStatusTimeline from './OrderStatusTimeline.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));
|
||||
@@ -153,20 +134,21 @@ const model = buildOmdeListModel();
|
||||
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
|
||||
const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const detailSnapshot = ref('');
|
||||
const visibleList = ref(false);
|
||||
const visibleSts = ref(false);
|
||||
const editMode = ref(false);
|
||||
const saving = ref(false);
|
||||
const addVariantId = ref('');
|
||||
const addQty = ref(1);
|
||||
|
||||
const formatDate = (v: string | null) => (v ? jdate(v) : '');
|
||||
const canEditOrder = computed(() => hasPermission('order.omde.view.edit'));
|
||||
|
||||
/** 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;
|
||||
@@ -174,9 +156,123 @@ async function loadDetail(id: string) {
|
||||
|
||||
async function viewOrder(row: Record<string, any>) {
|
||||
tempRow.value = await loadDetail(row.id);
|
||||
detailSnapshot.value = JSON.stringify(tempRow.value);
|
||||
editMode.value = false;
|
||||
visibleList.value = true;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
visibleList.value = false;
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (detailSnapshot.value) {
|
||||
try {
|
||||
tempRow.value = JSON.parse(detailSnapshot.value);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
}
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
if (!tempRow.value?.items) return;
|
||||
if (tempRow.value.items.length <= 1) {
|
||||
message.warning('حداقل یک آیتم باید در سفارش بماند');
|
||||
return;
|
||||
}
|
||||
tempRow.value.items.splice(index, 1);
|
||||
}
|
||||
|
||||
function onAddLine(payload: {
|
||||
variantId: string;
|
||||
qty: number;
|
||||
title?: string;
|
||||
variantTitle?: string;
|
||||
grams?: number;
|
||||
wagePercent?: number;
|
||||
flatRialCharge?: number;
|
||||
image?: string | null;
|
||||
}) {
|
||||
const vid = payload.variantId.trim();
|
||||
if (!vid) {
|
||||
message.warning('یک تنوع انتخاب کنید');
|
||||
return;
|
||||
}
|
||||
if (!tempRow.value) return;
|
||||
tempRow.value.items.push({
|
||||
productVariantId: vid,
|
||||
title: payload.title ?? 'آیتم جدید',
|
||||
variant_title: payload.variantTitle ?? vid,
|
||||
qty: payload.qty || 1,
|
||||
grams: payload.grams ?? 0,
|
||||
flatRialCharge: payload.flatRialCharge ?? 0,
|
||||
wagePercent: payload.wagePercent,
|
||||
rash: payload.grams ?? 0,
|
||||
image: payload.image ?? null,
|
||||
});
|
||||
addVariantId.value = '';
|
||||
addQty.value = 1;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!tempRow.value?.id) return;
|
||||
const items = (tempRow.value.items ?? [])
|
||||
.filter((it: any) => it.productVariantId)
|
||||
.map((it: any) => ({
|
||||
productVariantId: it.productVariantId,
|
||||
quantity: Number(it.qty) || 1,
|
||||
grams: Number(it.grams) || undefined,
|
||||
flatRialCharge: Number(it.flatRialCharge) || 0,
|
||||
rashWeightGrams: it.rash != null ? Number(it.rash) : undefined,
|
||||
wagePercent: it.wagePercent != null ? Number(it.wagePercent) : undefined,
|
||||
discountPercent: it.discountPercent != null ? Number(it.discountPercent) : undefined,
|
||||
}));
|
||||
if (!items.length) {
|
||||
message.error('حداقل یک آیتم لازم است');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('admin/orders/edit/propose', { orderId: tempRow.value.id, items });
|
||||
message.success('پیشنهاد ویرایش ثبت شد؛ پیامک برای مشتری ارسال میشود');
|
||||
editMode.value = false;
|
||||
tempRow.value = await loadDetail(tempRow.value.id);
|
||||
detailSnapshot.value = JSON.stringify(tempRow.value);
|
||||
tbl.value?.fetch();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت ویرایش');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function forceApplyEdit() {
|
||||
if (!tempRow.value?.id) return;
|
||||
try {
|
||||
await api.post('admin/orders/edit/force-apply', { orderId: tempRow.value.id });
|
||||
message.success('ویرایش بهصورت اجباری اعمال شد');
|
||||
tempRow.value = await loadDetail(tempRow.value.id);
|
||||
tbl.value?.fetch();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در اعمال');
|
||||
}
|
||||
}
|
||||
|
||||
async function discardEdit() {
|
||||
if (!tempRow.value?.id) return;
|
||||
try {
|
||||
await api.post('admin/orders/edit/discard', { orderId: tempRow.value.id });
|
||||
message.success('پیشنهاد ویرایش لغو شد');
|
||||
tempRow.value = await loadDetail(tempRow.value.id);
|
||||
tbl.value?.fetch();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در لغو');
|
||||
}
|
||||
}
|
||||
|
||||
async function showSts(row: Record<string, any>) {
|
||||
tempRow.value = await loadDetail(row.id);
|
||||
visibleSts.value = true;
|
||||
@@ -231,6 +327,9 @@ function deleteOrder(record: Record<string, any>) {
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mr-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
<!--
|
||||
Admin status rail — same language as the customer app's tlSheet.
|
||||
Rail (markers / stem / current) stays; actions stay the same logically
|
||||
(ثبت for every pending, حذف for proceeded except first) but sit quietly
|
||||
in one line so the list does not feel stacked and noisy.
|
||||
-->
|
||||
<template>
|
||||
<div v-if="activity.length" class="ost">
|
||||
<header class="ost__banner">
|
||||
<span class="ost__chip">وضعیت</span>
|
||||
<span class="ost__dash" aria-hidden="true" />
|
||||
<span
|
||||
v-if="current"
|
||||
class="ost__badge"
|
||||
:style="{
|
||||
background: orderStatusColor(current.status).background,
|
||||
color: orderStatusColor(current.status).font,
|
||||
}"
|
||||
>
|
||||
<component :is="orderStatusIcon(current.status)" />
|
||||
{{ nodeTitle(current) }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ol class="ost__rail">
|
||||
<li
|
||||
v-for="(node, index) in nodes"
|
||||
:key="node.status || index"
|
||||
class="ost__node"
|
||||
:class="[`is-${node.state}`]"
|
||||
>
|
||||
<div class="ost__axis" aria-hidden="true">
|
||||
<span
|
||||
v-if="node.state === 'current'"
|
||||
class="ost__halo"
|
||||
:style="{ color: orderStatusColor(node.status).font }"
|
||||
/>
|
||||
<span class="ost__marker" :style="markerStyle(node)">
|
||||
<component :is="orderStatusIcon(node.status)" />
|
||||
</span>
|
||||
<span
|
||||
v-if="index < nodes.length - 1"
|
||||
class="ost__stem"
|
||||
:style="node.state === 'pending' ? undefined : { background: orderStatusColor(node.status).font }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ost__main">
|
||||
<div class="ost__row">
|
||||
<div class="ost__copy">
|
||||
<div class="ost__status">{{ nodeTitle(node) }}</div>
|
||||
<p v-if="node.proceed && node.note" class="ost__note">{{ node.note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="ost__meta">
|
||||
<time v-if="node.proceed && node.uaj" class="ost__when" dir="ltr">{{ formatWhen(node.uaj) }}</time>
|
||||
<span v-else-if="node.proceed" class="ost__when is-wait">—</span>
|
||||
<span v-else class="ost__when is-wait">در انتظار</span>
|
||||
|
||||
<a-popconfirm
|
||||
v-if="node.proceed && canRemove && index > 0"
|
||||
ok-text="بله"
|
||||
cancel-text="بیخیال"
|
||||
placement="topLeft"
|
||||
@confirm="emit('remove', node)"
|
||||
>
|
||||
<template #title><p>آیا مطمئن به حذف وضعیت مورد نظر هستید ؟</p></template>
|
||||
<a-button
|
||||
danger
|
||||
ghost
|
||||
size="small"
|
||||
class="ost__iconBtn"
|
||||
aria-label="حذف وضعیت"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!node.proceed && canSubmit" class="ost__submit">
|
||||
<a-input
|
||||
v-model:value="node.note"
|
||||
size="small"
|
||||
placeholder="یادداشت"
|
||||
allow-clear
|
||||
@pressEnter="emit('submit', node)"
|
||||
/>
|
||||
<a-button size="small" type="primary" @click="emit('submit', node)">ثبت وضعیت</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<a-empty v-else description="مسیری برای این سفارش ثبت نشده است." />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import moment from 'moment-jalaali';
|
||||
import { DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
orderStatusColor,
|
||||
orderStatusIcon,
|
||||
orderStatusLabel,
|
||||
} from '../../utils/orderStatus';
|
||||
|
||||
export type ActivityItem = {
|
||||
status: string;
|
||||
title?: string;
|
||||
proceed?: boolean;
|
||||
uaj?: string | null;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type NodeState = 'done' | 'current' | 'pending';
|
||||
|
||||
type TimelineNode = ActivityItem & { state: NodeState };
|
||||
|
||||
const props = defineProps<{
|
||||
activity: ActivityItem[];
|
||||
canSubmit?: boolean;
|
||||
canRemove?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', item: ActivityItem): void;
|
||||
(e: 'remove', item: ActivityItem): void;
|
||||
}>();
|
||||
|
||||
const activity = computed(() => props.activity ?? []);
|
||||
|
||||
const lastProceedIdx = computed(() => {
|
||||
let idx = -1;
|
||||
activity.value.forEach((item, i) => {
|
||||
if (item.proceed) idx = i;
|
||||
});
|
||||
return idx;
|
||||
});
|
||||
|
||||
const nodes = computed((): TimelineNode[] =>
|
||||
activity.value.map((item, index) => {
|
||||
let state: NodeState = 'pending';
|
||||
if (item.proceed) {
|
||||
state = index === lastProceedIdx.value ? 'current' : 'done';
|
||||
}
|
||||
return { ...item, state };
|
||||
}),
|
||||
);
|
||||
|
||||
const current = computed(() => nodes.value.find((n) => n.state === 'current') ?? null);
|
||||
|
||||
function nodeTitle(node: ActivityItem) {
|
||||
return orderStatusLabel(node.status) || node.title || node.status;
|
||||
}
|
||||
|
||||
function formatWhen(v: string) {
|
||||
return moment(v).format('jYYYY/jMM/jDD - HH:mm');
|
||||
}
|
||||
|
||||
function markerStyle(node: TimelineNode) {
|
||||
if (node.state === 'pending') return undefined;
|
||||
const c = orderStatusColor(node.status);
|
||||
return { color: c.font, borderColor: c.font };
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ost__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ost__chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(20, 22, 26, 0.04);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.ost__dash {
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
border-top: 1.5px dashed #e5e7eb;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.ost__badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ost__badge :deep(.anticon) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ost__rail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.ost__node {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.ost__axis {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ost__halo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-left: -20px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ost__halo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, currentColor 32%, transparent) 0%,
|
||||
color-mix(in srgb, currentColor 12%, transparent) 38%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(1.5px);
|
||||
opacity: 0;
|
||||
animation: ostHalo 4.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ostHalo {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ost__halo::before {
|
||||
animation: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.ost__marker {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid currentColor;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ost__node.is-pending .ost__marker {
|
||||
color: #9ca3af;
|
||||
border-color: #9ca3af;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ost__stem {
|
||||
flex: 1 1 auto;
|
||||
width: 1.5px;
|
||||
margin: 4px 0;
|
||||
background: #e5e7eb;
|
||||
min-height: 12px;
|
||||
}
|
||||
|
||||
.ost__node.is-pending .ost__stem {
|
||||
width: 2px;
|
||||
background-image: repeating-linear-gradient(180deg, #e5e7eb 0 4px, transparent 4px 8px);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ost__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.ost__node:last-child .ost__main {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.ost__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ost__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ost__status {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ost__note {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ost__meta {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ost__when {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.ost__when.is-wait {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ost__node.is-pending .ost__status,
|
||||
.ost__node.is-pending .ost__when {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.ost__iconBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ost__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ost__submit .ant-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -42,78 +42,28 @@
|
||||
</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>
|
||||
<a-modal
|
||||
v-model:open="visibleSts"
|
||||
title="وضعیت سفارش"
|
||||
width="480px"
|
||||
wrap-class-name="detail-modal"
|
||||
>
|
||||
<OrderStatusTimeline
|
||||
v-if="tempRow"
|
||||
:activity="tempRow.activity || []"
|
||||
:can-submit="hasPermission('order.tamin.state.submit')"
|
||||
:can-remove="hasPermission('order.tamin.state.remove')"
|
||||
@submit="setSts"
|
||||
@remove="removeSts"
|
||||
/>
|
||||
<template #footer>
|
||||
<a-button @click="visibleSts = false">باشه</a-button>
|
||||
<a-button @click="visibleSts = false">بستن</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- مشاهده سفارش -->
|
||||
<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 />
|
||||
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="720px" wrap-class-name="detail-modal">
|
||||
<OrderDetailBody :order="tempRow" />
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="visibleList = false">باشه</a-button>
|
||||
@@ -128,10 +78,11 @@ import { ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import TooltipBtn from '../../components/tooltip-btn/TooltipBtn.vue';
|
||||
import OrderDetailBody from './OrderDetailBody.vue';
|
||||
import OrderStatusTimeline from './OrderStatusTimeline.vue';
|
||||
import { buildTaminListModel } from './TaminListModel';
|
||||
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 +94,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;
|
||||
|
||||
@@ -67,11 +67,14 @@ export default defineConfig(({ mode }) => {
|
||||
// stuck dev server is obvious rather than moving to a random port.
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': `http://localhost:${apiPort}`,
|
||||
// 127.0.0.1, not localhost: on Windows localhost often prefers ::1, and
|
||||
// another process can bind only IPv6 :3000 while our API is on IPv4 —
|
||||
// the proxy then hits the wrong app and every /api call 404s.
|
||||
'/api': `http://127.0.0.1:${apiPort}`,
|
||||
// Uploaded files (ProductImage.url etc.) are served by the API's own
|
||||
// static handler, not this dev server — without this they 404 in dev
|
||||
// even though production nginx (nginx.conf) already proxies them.
|
||||
'/uploads': `http://localhost:${apiPort}`,
|
||||
'/uploads': `http://127.0.0.1:${apiPort}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user