feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
+262
View File
@@ -0,0 +1,262 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/List.vue.
Farsi copy, permission gates, slot names, modal structure and the status
timeline are preserved. Vuesax's vx-card becomes a-card; the Vue 2 slot
syntax becomes Vue 3 named slots.
-->
<div id="page-user-list">
<a-card :title="is_branch ? 'لیست سفارش' : 'لیست سفارش های تک فروشی'">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.list.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('order.list.pdf')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="FileIcon"
title="دانلود pdf"
@click="viewPdf(record.id)"
/>
</div>
</template>
<template #items_len="{ text }">
<div v-if="text">{{ text.length }}</div>
</template>
<template #user="{ text, record }">
<div v-if="text">{{ text.name }}</div>
<div v-else>{{ record.customer.fname + ' ' + record.customer.lname }}</div>
</template>
<template #total_price="{ text }">
<div v-if="text">{{ priceUnit(text) }}</div>
</template>
<template #sts="{ text, record }">
<a v-if="hasPermission('order.list.sts')" href="" @click.prevent="showSts(record)">
{{ text }}
</a>
<span v-else>{{ text }}</span>
</template>
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش">
<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>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%">
<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>
</a-card>
</div>
</template>
<script setup lang="ts">
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 { buildOrderListModel } from './orderListModel';
import { useUsersStore } from '../../stores/users';
import { isBranch } from '../../utils';
import api from '../../api/client';
import { jdate, priceUnit } from '../../utils/filters';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildOrderListModel();
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';
}
async function showSts(row: Record<string, any>) {
// legacy: branch orders have no editable status timeline
if (!row.branch_id) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
}
}
async function loadDetail(id: string) {
const { data } = await api.get(`admin/orders/${id}`);
return data;
}
async function setSts(row: Record<string, any>) {
try {
await api.post('single/order-sts/edit', row);
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function removeSts(row: Record<string, any>) {
try {
row.remove = true;
await api.post('single/order-sts/edit', row);
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
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;
}
async function viewPdf(id: string) {
// Legacy opened the PHP backend's PDF endpoint directly.
// No equivalent exists in the new API yet.
message.warning('خروجی PDF هنوز در بک‌اند جدید پیاده‌سازی نشده است');
}
</script>
<style scoped>
.flex-col {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.justify-between {
justify-content: space-between;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,223 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/MakeOrder.vue.
These are custom/made-to-order design requests (PreOrder in the new schema).
Farsi copy, permission gates and the edit-in-place flow are preserved; the
delete action and the report link are COMMENTED OUT in legacy and stay absent.
-->
<div id="page-user-list">
<a-card title="سفارش های ساختنی">
<XTable ref="tbl" :model="model">
<template #name="{ record }">
{{ record.customer?.name }}
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.makeOrder.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
</div>
</template>
<template #sts="{ record }">
<a
v-if="hasPermission('order.makeOrder.state')"
href=""
@click.prevent="viewOrder(record)"
>{{ record.sts_str_fa }}</a
>
<span v-else>{{ record.sts_str_fa }}</span>
</template>
</XTable>
<!-- مشاهده سفارش -->
<a-modal
v-model:open="visibleList"
title="مشاهده سفارش"
width="80%"
@cancel="onCloseDetail"
>
<div v-if="tempRow">
<a-descriptions>
<a-descriptions-item label="مشتری">{{ customerName }}</a-descriptions-item>
<a-descriptions-item label="تلفن همراه">{{ customerMobile }}</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">
{{ formatDate(tempRow.time_creat_fa) || '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ tempRow.sts_str_fa || '-' }}</a-descriptions-item>
</a-descriptions>
<a-card>
<template #title>
<div class="flex">
<div class="flex-1">آیتم های سفارش</div>
<div>
<TooltipBtn
v-if="hasPermission('order.makeOrder.view.edit')"
icon-class="w-5 h-5 text-success"
title="ویرایش"
:icon="getIcon"
@click="editW"
/>
</div>
</div>
</template>
<div class="flex gap-8">
<div>
<img
v-if="tempRow.designImageUrl"
:src="tempRow.designImageUrl"
width="90px"
height="90px"
style="object-fit: scale-down"
/>
<a-empty v-else :image="undefined" description="بدون تصویر" />
</div>
<div class="flex">
<a-descriptions>
<a-descriptions-item label="رنگ محصول">
<div v-if="!editMode">{{ tempRow.color || '-' }}</div>
<a-input
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.color"
/>
</a-descriptions-item>
<a-descriptions-item label="رنگ مینا">
<div v-if="!editMode">{{ tempRow.color_mina || '-' }}</div>
<a-input
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.color_mina"
/>
</a-descriptions-item>
<a-descriptions-item label="رنگ سنگ">
<div v-if="!editMode">{{ tempRow.color_stone || '-' }}</div>
<a-input
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.color_stone"
/>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-tag v-if="!editMode" color="orange">{{ tempRow.sum || 0 }}</a-tag>
<a-input-number
v-if="editMode && hasPermission('order.makeOrder.view.edit')"
v-model:value="tempRow.sum"
class="w-full"
/>
</a-descriptions-item>
<a-descriptions-item label="انتخاب کارگاه">
<a-select
v-model:value="tempRow.workshop_id"
placeholder="لطفا یک کارگاه را انتخاب کنید"
style="min-width: 200px"
>
<a-select-option v-for="item in option" :key="item.id" :value="item.id">
{{ item.name }}
</a-select-option>
</a-select>
</a-descriptions-item>
</a-descriptions>
</div>
</div>
</a-card>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visibleList = false">باشه</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
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 { buildMakeOrderModel } from './MakeOrderModel';
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));
const model = buildMakeOrderModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleList = ref(false);
const editMode = ref(false);
const option = ref<{ id: string; name: string }[]>([]);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
const getIcon = computed(() => (!editMode.value ? 'EditIcon' : 'CheckIcon'));
const customerName = computed(() => tempRow.value?.customer?.name ?? '-');
const customerMobile = computed(() => tempRow.value?.customer?.mobile ?? '-');
async function loadWorkshops() {
try {
const { data } = await api.post('workshop/table', { page: 1, limit: 100 });
option.value = (data.rows ?? []).map((w: any) => ({ id: w.id, name: w.name }));
} catch (e) {
// Legacy silently ignored a failed workshop fetch too.
console.log(e);
}
}
async function viewOrder(row: Record<string, any>) {
const { data } = await api.get(`admin/pre-orders/${row.id}`);
tempRow.value = data;
visibleList.value = true;
loadWorkshops();
}
/**
* legacy editW(): the same button toggles into edit mode and, on the second
* click, saves. Legacy's save was `PUT custom-order/{id}`.
*/
async function editW() {
if (!editMode.value) {
editMode.value = true;
return;
}
try {
await api.put(`custom-order/${tempRow.value?.id}`, {
color: tempRow.value?.color,
color_mina: tempRow.value?.color_mina,
color_stone: tempRow.value?.color_stone,
});
editMode.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
/** legacy: @cancel="$refs.tbl.fetch(); editMode=false" */
function onCloseDetail() {
tbl.value?.fetch();
editMode.value = false;
}
</script>
<style scoped>
.flex-1 {
flex: 1;
}
.gap-8 {
gap: 2rem;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,37 @@
// Ported from legacy henza_admin/src/views/order/MakeOrderModel.js
//
// Column keys, Farsi titles and filter types preserved exactly, including
// legacy's commented-out columns (total_price, items, branch.name) which stay
// absent. The gate on this table's status column is 'order.tamin.state' in
// legacy — NOT 'order.makeOrder.state' as the page name would suggest. Kept.
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildMakeOrderModel(): XTbl {
const users = useUsersStore();
const tbl = new XTbl('admin/pre-orders/list', 'سفارش های ساختنی');
tbl.add(new Xtc('customer.id', 'شماره').filterKey('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('color', 'رنگ محصول').filterKey('colorName').filterText());
tbl.add(new Xtc('size', 'سایز محصول').filterKey('sizeSpec').filterText());
tbl.add(new Xtc('color_mina', 'رنگ مینا').filterKey('minaColor').filterText());
tbl.add(new Xtc('color_stone', 'رنگ سنگ').filterKey('stoneColor').filterText());
tbl.add(new Xtc('sum', 'تعداد').filterText());
tbl.add(new Xtc('time_creat_fa', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(
new Xtc('time_update_fa', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort(),
);
tbl.add(new Xtc('customer.name', 'ثبت کننده').renderSlot('name').noSort());
// legacy gate: 'order.tamin.state' (not makeOrder) — preserved verbatim
if (users.roles && users.roles.includes('order.tamin.state')) {
tbl.add(new Xtc('sts_str_fa', 'وضعیت').renderSlot('sts').noSort());
}
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildMakeOrderModel;
@@ -0,0 +1,237 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/OmdeList.vue.
Farsi copy, permission gates, slot names and modal structure preserved.
The report download <a href> is kept as a link (legacy opened the PHP
report endpoint directly) but now points at the new API.
-->
<div id="page-user-list">
<a-card title="لیست سفارش های عمده">
<XTable ref="tbl" :model="model">
<template #city="{ text }">
<div v-if="text">{{ getCity(text) }}</div>
<div v-else>-</div>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.omde.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('order.omde.remove')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="TrashIcon"
title="حذف"
pop-title="آیا مطمئن به حدف رکورد مورد نظر هستید؟"
@click="deleteOrder(record)"
/>
</div>
</template>
<template #items_len="{ text }">
<div v-if="text">{{ text.length }}</div>
</template>
<template #sts_str="{ record }">
<a
v-if="hasPermission('order.omde.state')"
href=""
@click.prevent="showSts(record)"
>{{ record.sts_str }}</a
>
<span v-else>{{ record.sts_str }}</span>
</template>
<template #confirmed_str="{ record }">
{{ record.confirmed_str }}
</template>
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش">
<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>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%">
<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>
</a-card>
</div>
</template>
<script setup lang="ts">
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 { buildOmdeListModel } from './OmdeListModel';
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));
const model = buildOmdeListModel();
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 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;
}
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleList.value = true;
}
async function showSts(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
}
async function setSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', { orderId: tempRow.value?.id, status: row.status, note: row.note });
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function removeSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
remove: true,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses to unwind a status that already posted debt; surface
// its Farsi explanation rather than a generic failure.
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
function deleteOrder(record: Record<string, any>) {
api
.post('delete-order', { id: record.id })
.then(() => {
message.info('سفارش با موفقیت حذف گردید');
tbl.value?.fetch();
})
.catch((error) => {
console.log(error);
message.error(error?.response?.data?.message ?? 'خطا در حذف');
});
}
</script>
<style scoped>
.flex-col {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,56 @@
// Ported from legacy henza_admin/src/views/order/OmdeListModel.js
//
// Column keys, Farsi titles, filter types, the status/confirmed value maps and
// the 'order.omde.state' gate are preserved exactly. Changes: the url is the new
// clean REST path (legacy used the absolute 'https://henzagold.com/oapi/admin/
// major/order/table'), and roles/cities come from Pinia instead of Vuex.
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildOmdeListModel(): XTbl {
const users = useUsersStore();
// legacy: fetch the city list once if the filter map is empty
if (Object.keys(users.cityFilters).length < 1) {
users.getCities();
}
const tbl = new XTbl('admin/orders/wholesale/list', 'لیست سفارش های عمده');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText());
tbl.add(
new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort(),
);
tbl.add(new Xtc('input', 'جمع کل سفارش').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').filterText());
// legacy kept the .filterSelect(cityFilters) call commented out — preserved.
tbl.add(new Xtc('customer.city_id', 'شهر').hidden().renderSlot('city'));
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());
if (users.roles && users.roles.includes('order.omde.state')) {
tbl.add(
new Xtc('sts', 'وضعیت').renderSlot('sts_str').noSort().filterKey('status').filterSelect({
0: 'معلق',
1: 'ثبت سفارش',
2: 'تایید مدیر سیستم',
3: 'در حال تامین',
4: 'در حال آماده سازی ',
5: 'تحویل مسئول فروش',
6: 'تحویل مشتری ',
}),
);
}
tbl.add(
new Xtc('confirmed', 'وضعیت تایید مشتری').renderSlot('confirmed_str').noSort().filterSelect({
1: 'تایید شده',
2: 'در انتظار تایید',
}),
);
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildOmdeListModel;
@@ -0,0 +1,208 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/TaminList.vue.
The card title in legacy is 'لیست سفارش های عمده' — the SAME string the
wholesale list uses, even though the nav item for this page reads
'لیست تامین'. Preserved rather than "corrected". The delete action exists in
the legacy markup but is COMMENTED OUT — preserved as absent, not re-added.
-->
<div id="page-user-list">
<a-card title="لیست سفارش های عمده">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('order.tamin.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
</div>
</template>
<template #items_len="{ text }">
<div v-if="text">{{ text.length }}</div>
</template>
<template #sts_str="{ record }">
<a
v-if="hasPermission('order.tamin.state')"
href=""
@click.prevent="showSts(record)"
>{{ record.sts_str }}</a
>
<span v-else>{{ record.sts_str }}</span>
</template>
<template #confirmed_str="{ record }">
{{ record.confirmed_str }}
</template>
</XTable>
<!-- وضعیت سفارش -->
<a-modal v-model:open="visibleSts" title="وضعیت سفارش">
<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>
<!-- مشاهده سفارش -->
<a-modal v-model:open="visibleList" title="مشاهده سفارش" width="80%">
<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>
</a-card>
</div>
</template>
<script setup lang="ts">
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 { 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));
const model = buildTaminListModel();
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 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;
}
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleList.value = true;
}
async function showSts(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
}
async function setSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
note: row.note,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ویرایش');
}
}
async function removeSts(row: Record<string, any>) {
try {
await api.post('order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
remove: true,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
}
}
</script>
<style scoped>
.flex-col {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.mr-3 {
margin-left: 0.75rem;
}
</style>
@@ -0,0 +1,37 @@
// Ported from legacy henza_admin/src/views/order/TaminListModel.js
//
// Note legacy's title string for this table is also 'لیست سفارش های عمده'
// (same as OmdeListModel) — preserved rather than "corrected".
import XTbl, { Xtc } from '../../components/x-table/model';
import { useUsersStore } from '../../stores/users';
export function buildTaminListModel(): XTbl {
const users = useUsersStore();
const tbl = new XTbl('admin/orders/tamin/list', 'لیست سفارش های عمده');
tbl.add(new Xtc('id', 'شماره').filterNumber());
tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort());
tbl.add(new Xtc('input', 'ورودی').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('wage', 'اجرت').filterKey('wageWeightGrams').filterText().noSort());
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());
if (users.roles && users.roles.includes('order.tamin.state')) {
// legacy called .filterSelect() with NO argument here, leaving the dropdown
// empty. Preserved exactly — see the note on filterSelect in x-table/model.ts.
tbl.add(new Xtc('sts', 'وضعیت').renderSlot('sts_str').noSort().filterSelect());
}
tbl.add(
new Xtc('confirmed', 'وضعیت تایید مشتری').renderSlot('confirmed_str').noSort().filterSelect({
1: 'تایید شده',
2: 'در انتظار تایید',
}),
);
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildTaminListModel;
+424
View File
@@ -0,0 +1,424 @@
<template>
<!--
Ported from legacy henza_admin/src/views/order/create.vue — the admin order
builder: pick products, add variants to a cart, then submit as an order.
Preserved: the Farsi copy, the cart table's column set, the per-row quantity
cap at the variant's stock (Infinity when stock is 0, as legacy had it), the
inline weight editor, and the delivery-status / sale-type dropdowns.
Price columns: legacy read `price` and `price_with_discount` off each variant,
which the redesigned catalog does not store (gold price is quoted live and
wage is a percentage). Those cells show the gram weight and wage instead of
inventing a Rial figure — see docs/ADMIN-PORT-PLAN.md.
-->
<a-spin v-if="hasPermission('order.create')" :spinning="loading">
<a-card title="لیست محصولات">
<XTable ref="tblProducts" :model="cols">
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
/>
</template>
<template #op="{ record }">
<a-dropdown>
<a-button style="margin-left: 8px">
افزودن
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="onVariantPick(record, $event)">
<a-menu-item v-for="item in record.variants_all" :key="item.id">
افزودن تنوع {{ item.title }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</XTable>
<a-card v-if="items.length > 0" title="ثبت سفارش" class="mt-5">
<a-table :columns="columns" :data-source="items" bordered :pagination="false" row-key="vid">
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'delete'">
<CloseOutlined class="cursor-pointer" @click="cart.remove(record)" />
</template>
<template v-else-if="column.dataIndex === 'product'">
<div class="flex items-center">
<img :src="getImage(record.product)" width="50px" />
{{ record.product.title }}
</div>
</template>
<template v-else-if="column.dataIndex === 'wage'">
{{ getVariant(record).percentage_pay }}٪
</template>
<template v-else-if="column.dataIndex === 'qty'">
<!--
legacy capped quantity at the variant's stock, falling back to
Infinity when stock is 0 (which is how a pre-order variant stays
orderable). Preserved.
-->
<a-input-number
v-model:value="items[index].qty"
:min="1"
:max="maxQtyFor(index)"
@change="onQtyChange(index, $event)"
/>
</template>
<template v-else-if="column.dataIndex === 'weight'">
<div class="flex">
<template v-if="!variant.grams || variant.id !== getVariant(record).id">
<span style="width: max-content">{{ getVariant(record).title }}</span>
<EditOutlined class="cursor-pointer ml-2 text-primary" @click="toEdit(record)" />
</template>
<template v-else>
<a-input-number v-model:value="variant.grams" />
<CheckOutlined class="cursor-pointer ml-2 text-success" @click="editGram" />
</template>
</div>
</template>
<template v-else-if="column.dataIndex === 'total'">
{{ rowWeight(record) }} گرم
</template>
</template>
</a-table>
<a-form layout="vertical" class="mt-4" @submit.prevent="submitOrder">
<div class="grid-5">
<a-form-item
label="مشتری"
:validate-status="err.customer_id ? 'error' : ''"
:help="err.customer_id"
>
<a-select
v-model:value="form.customer_id"
show-search
option-filter-prop="children"
:filter-option="filterOption"
>
<a-select-option v-for="item in customers" :key="item.id" :value="item.id">
{{ `${item.id}-${item.fname} ${item.lname}` }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="وضعیت تحویل"
:validate-status="err.sts ? 'error' : ''"
:help="err.sts"
>
<a-select v-model:value="form.sts">
<a-select-option v-for="(label, code) in sts" :key="code" :value="Number(code)">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نوع فروش" :validate-status="err.type ? 'error' : ''" :help="err.type">
<a-select v-model:value="form.type">
<a-select-option v-for="(label, code) in types" :key="code" :value="Number(code)">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام دریافت کننده">
<a-input v-model:value="form.shipping_address.reciver_fname" />
</a-form-item>
<a-form-item label="نام خانوادگی دریافت کننده">
<a-input v-model:value="form.shipping_address.reciver_lname" />
</a-form-item>
<a-form-item label="استان">
<a-input v-model:value="form.shipping_address.province" />
</a-form-item>
<a-form-item label="شهر">
<a-input v-model:value="form.shipping_address.city" />
</a-form-item>
<a-form-item label="کد پستی">
<a-input v-model:value="form.shipping_address.zipcode" />
</a-form-item>
<a-form-item label="تلفن همراه">
<a-input v-model:value="form.shipping_address.reciver_phone" />
</a-form-item>
<a-form-item label="تلفن ثابت">
<a-input v-model:value="form.shipping_address.reciver_fixed_phone" />
</a-form-item>
</div>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.note" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
<a-form-item label="آدرس">
<a-textarea
v-model:value="form.shipping_address.address"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<div class="totals">
<a-tag color="blue">مجموع وزن: {{ totalWeight }} گرم</a-tag>
</div>
<a-button
type="primary"
class="mt-4"
:disabled="!items || items.length < 1"
@click="submitOrder"
>
ثبت تغییرات
</a-button>
</a-form>
</a-card>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
CheckOutlined,
CloseOutlined,
DownOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildProductListModel } from '../apps/product/productListModel';
import { useCartStore, type CartItem, type CartVariant } from '../../stores/cart';
import { useUsersStore } from '../../stores/users';
import { useAuthStore } from '../../stores/auth';
import api from '../../api/client';
const router = useRouter();
const users = useUsersStore();
const auth = useAuthStore();
const cart = useCartStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const noImage = '/img/no-items.png';
const cols = buildProductListModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false);
const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
const types = ref<Record<number, string>>({});
// legacy sts map, verbatim
const sts: Record<number, string> = {
1: 'ثبت سفارش',
2: 'تایید مدیر سیستم',
3: 'در حال تامین',
4: 'در حال آماده سازی',
5: 'ارسال',
6: 'تحویل مشتری',
};
const form = reactive<Record<string, any>>({
customer_id: undefined,
sts: undefined,
type: undefined,
note: '',
shipping_address: {
reciver_fname: '',
reciver_lname: '',
province: '',
city: '',
zipcode: '',
reciver_phone: '',
reciver_fixed_phone: '',
address: '',
},
});
const err = reactive<Record<string, string>>({});
/** legacy: the logged-in admin is recorded as the order's reagent */
const reagent_id = computed(() => auth.user?.id);
const items = computed(() => cart.items);
const columns = [
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
{ title: 'کد محصول', dataIndex: 'pid', align: 'center', width: 90 },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'اجرت', dataIndex: 'wage', align: 'center' },
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
];
// ── inline weight editor
const variant = reactive<{ id?: string; grams?: number }>({});
function getVariant(item: CartItem): CartVariant & Record<string, any> {
return (item.product.variants_all.find((x) => x.id === item.vid) ?? {}) as CartVariant;
}
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
}
/** legacy totalWeight — note it does NOT multiply by qty. Preserved. */
const totalWeight = computed(() => {
let total = 0;
for (const item of items.value) {
total += Number(getVariant(item).grams ?? 0);
}
return total;
});
function rowWeight(record: CartItem) {
return Number(getVariant(record).grams ?? 0) * record.qty;
}
/** Cap a row's quantity at the variant's stock; 0 stock means uncapped (legacy). */
function maxQtyFor(index: number): number {
const item = items.value[index];
if (!item) return Infinity;
const stock = Number(getVariant(item).qty ?? 0);
return stock > 0 ? stock : Infinity;
}
function onQtyChange(index: number, value: unknown) {
cart.updateItemQuantity({ index, quantity: Number(value) });
}
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
addToCart(record, String(e.key));
}
function addToCart(record: Record<string, any>, variantId: string) {
cart.add({
vid: variantId,
pid: record.id,
product: record as any,
qty: 1,
promotion_id: record.promotion && record.promotion.id ? record.promotion.id : null,
});
message.info('اضافه شد');
}
function toEdit(record: CartItem) {
const v = getVariant(record);
variant.id = v.id;
variant.grams = Number(v.grams ?? 0);
}
/**
* legacy editGram(): posted a NEW variant titled "<grams> گرم" and re-inited the
* cart. Kept — it creates a bespoke weight for this order rather than mutating
* the catalog variant other orders share.
*/
async function editGram() {
if (!variant.id || !variant.grams) return;
try {
loading.value = true;
const current = items.value.find((i) => i.vid === variant.id);
await api.post('add/variant', {
product_id: current?.pid,
title: `${variant.grams} گرم`,
grams: variant.grams,
});
await cart.init();
variant.id = undefined;
variant.grams = undefined;
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت وزن');
} finally {
loading.value = false;
}
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.includes(input.toLowerCase());
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.customer_id) err.customer_id = 'مقدار را وارد نمایید';
if (form.sts === undefined) err.sts = 'مقدار را وارد نمایید';
if (form.type === undefined) err.type = 'مقدار را وارد نمایید';
return Object.keys(err).length === 0;
}
async function submitOrder() {
if (!validate()) return;
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return {
vid: x.vid,
qty: x.qty,
percent: v.percentage_pay,
promotion_id: x.promotion_id,
};
});
// Legacy also sent browser-computed total_price / total_weight /
// gold_price_original. The server recomputes those from the variant
// snapshots, so they are not sent.
await api.post('order', {
...form,
reagent_id: reagent_id.value,
app: 'app',
order_items,
});
message.info('عملیات با موفقیت انجام شد');
cart.clear();
router.push('/order/list');
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [t, c] = await Promise.all([
api.get('order/type'),
api.post('single-customer', { page: 1, limit: 500 }),
]);
types.value = t.data;
customers.value = c.data.rows ?? [];
} catch (e) {
console.log(e);
}
await cart.init();
});
</script>
<style scoped>
.grid-5 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.ml-2 {
margin-right: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-5 {
margin-top: 1.25rem;
}
.totals {
margin: 1rem 0;
}
</style>
@@ -0,0 +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
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('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());
// legacy: gated on store.state.users.roles.includes('order.tamin.state')
const users = useUsersStore();
if (users.roles && users.roles.includes('order.tamin.state')) {
tbl.add(new Xtc('sts_str', 'وضعیت').renderSlot('sts').noSort());
tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort());
}
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildOrderListModel;