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:
saeed
2026-10-28 14:29:12 +03:30
co-authored by Cursor
parent 4e09a66fbf
commit feb5727c0e
429 changed files with 3942 additions and 30302 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+1
View File
@@ -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',
+75
View File
@@ -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()
+167 -112
View File
@@ -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;
}
+175 -76
View File
@@ -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>
+19 -74
View File
@@ -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;
+5 -2
View File
@@ -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}`,
},
},
};