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
@@ -0,0 +1,29 @@
// Ported from legacy henza_admin/src/views/customer/Comments/CommentListModel.js
//
// Column keys, Farsi titles and slots preserved exactly — including legacy's
// quirk that 'product.title' (a text column) is declared .filterNumber(), and
// that an 'sts' column is added once commented-out and once live.
import XTbl, { Xtc } from '../../../components/x-table/model';
import { useUsersStore } from '../../../stores/users';
export function buildCommentListModel(): XTbl {
const users = useUsersStore();
const tbl = new XTbl('admin/comments/list', 'لیست دیدگاه ها');
tbl.add(new Xtc('product_id', 'شناسه محصول').filterNumber().hidden());
// legacy declares filterNumber() on a title column — preserved
tbl.add(new Xtc('product.title', 'نام محصول').filterNumber().renderSlot('productName'));
tbl.add(new Xtc('customer.id', 'شناسه نویسنده دیدگاه').filterNumber().hidden());
tbl.add(new Xtc('customer', 'نام نویسنده ').filterText().renderSlot('customer'));
tbl.add(new Xtc('reply', 'پاسخ').renderSlot('reply').noSort());
tbl.add(new Xtc('sts', 'وضعیت').renderSlot('sts').noSort());
if (users.roles && users.roles.includes('comment.state')) {
tbl.add(new Xtc('changeState', 'تغییر وضعیت').renderSlot('changeState').noSort());
}
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildCommentListModel;
@@ -0,0 +1,273 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/Comments/Comments.vue.
Farsi copy, permission gates, slot names and the status dropdown are
preserved. The inline avatar SVG is replaced by an ant icon; the hardcoded
legacy asset path (/admin/img/henza-icon.2b1e8c97.svg — a build hash that no
longer exists) becomes the bundled logo.
-->
<div id="page-user-list">
<a-card title="لیست دیدگاه ها">
<XTable ref="tbl" :model="model">
<template #customer="{ text }">
{{ text && text.fname && text.lname ? `${text.fname} ${text.lname}` : 'بدون نام' }}
</template>
<template #productName="{ record }">
<a :href="productLink(record)" target="_blank">{{ record.product.title }}</a>
</template>
<template #sts="{ text }">
<a-tag v-if="text == 1">در انتظار تایید</a-tag>
<a-tag v-else-if="text == 2" color="green">تایید شده</a-tag>
<a-tag v-else color="red">عدم تایید</a-tag>
</template>
<template #reply="{ text }">
<a-tag v-if="text && text.length > 0 && text[0].comment !== null" color="green">
پاسخ داده شده
</a-tag>
<a-tag v-else>بدون پاسخ</a-tag>
</template>
<template #changeState="{ record }">
<a-dropdown v-if="hasPermission('comment.state')">
<a-tag
style="cursor: pointer"
:color="record.sts === 1 ? '' : record.sts === 2 ? 'green' : record.sts === 3 ? 'red' : ''"
@click="(e: Event) => e.preventDefault()"
>
{{ record.sts === 1 ? 'در انتظار تایید' : record.sts === 2 ? 'تایید' : 'عدم تایید' }}
<DownOutlined />
</a-tag>
<template #overlay>
<a-menu>
<a-menu-item>
<div
@click="
record.sts !== 2
? changeSts(record.id, 2)
: message.error('حالت فعلی تایید می باشد')
"
>
تایید
</div>
</a-menu-item>
<a-menu-item class="red">
<div
@click="
record.sts !== 3
? changeSts(record.id, 3)
: message.error('حالت فعلی عدم تایید می باشد')
"
>
عدم تایید
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('comment.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="EyeIcon"
title="مشاهده دیدگاه"
@click="viewComment(record)"
/>
<TooltipBtn
v-if="hasPermission('comment.remove')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="TrashIcon"
pop-title="دیدگاه حذف شود؟"
@click="deleteComment(record.id)"
/>
</div>
</template>
</XTable>
<!-- مشاهده دیدگاه -->
<a-modal v-model:open="visibleComment" title="مشاهده دیدگاه" width="80%">
<div v-if="tempRow" class="comments-list">
<ul>
<li class="comment-item">
<span class="img">
<UserOutlined style="font-size: 48px; color: #1c2d56" />
</span>
<div class="comment-body">
<h5>
{{
tempRow.customer && tempRow.customer.fname && tempRow.customer.lname
? `${tempRow.customer.fname} ${tempRow.customer.lname}`
: 'بدون نام'
}}
</h5>
<span class="time">
ارسال شده در تاریخ : {{ tempRow.caj ? String(tempRow.caj).split('-')[0] : null }}
</span>
<a-form-item style="width: 100%">
<a-textarea
v-model:value="commentText"
:disabled="!hasPermission('comment.view.edit')"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
</div>
<ul>
<li class="comment-item">
<img src="../../../assets/images/logo/henza-icon.svg" alt="comment author" class="img border" />
<div class="comment-body">
<h5>ادمین</h5>
<span v-if="tempRow.reply && tempRow.reply[0]" class="time">
ارسال شده در تاریخ :
{{ tempRow.reply[0].caj ? String(tempRow.reply[0].caj).split('-')[0] : '' }}
</span>
<a-form-item style="width: 100%">
<a-textarea
v-model:value="replyText"
:disabled="!hasPermission('comment.view.edit')"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<a-button
v-if="(showChangeReplyBtn || showChangeCommentBtn) && hasPermission('comment.view.edit')"
class="mr-4"
@click="submitChanges(tempRow)"
>
ثبت تغییرات
</a-button>
</div>
</li>
</ul>
</li>
</ul>
</div>
<template #footer>
<a-button @click="visibleComment = 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 { DownOutlined, UserOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildCommentListModel } from './CommentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildCommentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleComment = ref(false);
const commentText = ref('');
const replyText = ref('');
const showChangeCommentBtn = computed(() => commentText.value !== (tempRow.value?.comment ?? ''));
const showChangeReplyBtn = computed(
() => replyText.value !== (tempRow.value?.reply?.[0]?.comment ?? ''),
);
/**
* legacy built a storefront link from the product title:
* https://henzagold.com/product/<title-with-dashes>/<id>
* The host is kept because it points at the live public site, not the API.
*/
function productLink(record: Record<string, any>) {
const slug = String(record.product?.title ?? '').split(' ').join('-');
return `https://henzagold.com/product/${slug}/${record.product?.id}`;
}
async function changeSts(id: string, sts: number) {
try {
await api.post('comment/sts', { id, sts });
message.info('تغییرات ثبت شد ');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
function viewComment(record: Record<string, any>) {
tempRow.value = record;
commentText.value = record.comment ?? '';
replyText.value = record.reply?.[0]?.comment ?? '';
visibleComment.value = true;
}
async function deleteComment(id: string) {
try {
await api.delete(`comment/${id}`);
message.info('دیدگاه حذف شد');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در حذف');
}
}
async function submitChanges(row: Record<string, any>) {
try {
await api.post('comment', {
product_id: row.product_id,
customer_id: row.customer_id,
comment: replyText.value,
reply_to: row.id,
});
message.info('تغییرات ثبت شد ');
visibleComment.value = false;
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ثبت تغییرات');
}
}
</script>
<style scoped>
.mr-3 {
margin-left: 0.75rem;
}
.comments-list ul {
list-style: none;
padding-right: 0;
}
.comment-item {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.comment-item .img {
width: 65px;
height: 65px;
display: flex;
align-items: center;
justify-content: center;
}
.comment-body {
flex: 1;
min-width: 240px;
}
.comment-body .time {
font-size: 12px;
color: #888;
}
.border {
border: 1px solid grey;
border-radius: 50%;
}
</style>
@@ -0,0 +1,210 @@
<template>
<!--
Shared implementation of legacy ViewOmdeCustomer.vue (376 lines) and
ViewSingleCustomer.vue (425 lines) — the customer detail screens.
Both show the same four panels (profile, orders, financial documents, wallet)
against the same customer record; legacy duplicated the whole page per
customer kind. The differing bits are parameterised:
- the wholesale screen adds an activity-log panel (legacy log/ago/{id})
- the retail screen adds a wallet panel (legacy single/wallet/{id})
-->
<a-spin :spinning="loading">
<a-card :title="title">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="نام">{{ customer?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="موبایل">{{ customer?.mobile ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کد ملی">
{{ customer?.national_code ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="شهر">{{ customer?.city?.title ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کارشناس فروش">
{{ customer?.reagent?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">
<a-tag :color="customer?.active === 1 ? 'green' : 'red'">
{{ customer?.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="سطح اعتبار">
{{ customer?.gram_level ?? '-' }} گرم
</a-descriptions-item>
<a-descriptions-item label="بدهی گرمی">{{ customer?.remain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="بدهی ریالی">
{{ customer?.rial_remain ?? '-' }}
</a-descriptions-item>
</a-descriptions>
<div v-if="dashboard" class="stats mt-4">
<a-tag color="blue">کل سفارش‌ها: {{ dashboard.total_order }}</a-tag>
<a-tag color="orange">سفارش باز: {{ dashboard.open_order }}</a-tag>
<a-tag color="green">اسناد: {{ dashboard.ledger_entries }}</a-tag>
</div>
</a-card>
<a-tabs class="mt-4" :default-active-key="defaultTab">
<a-tab-pane key="orders" tab="سفارش ها">
<a-table
size="small"
:data-source="orders"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents" tab="سند های مالی">
<a-table
size="small"
:data-source="documents"
:columns="documentColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'confirmed'">
<a-tag :color="record.confirmed === 1 ? 'green' : ''">
{{ record.confirmed === 1 ? 'تایید شده' : 'تایید نشده' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showWallet" key="wallet" tab="کیف پول">
<a-table
size="small"
:data-source="wallet"
:columns="walletColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ jdate(record.expires_at) }}
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showActivity" key="activity" tab="فعالیت ها">
<a-table
size="small"
:data-source="activity"
:columns="activityColumns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdateTime(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { jdate, jdateTime } from '../../utils/filters';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
title?: string;
/** The retail screen shows the wallet panel. */
showWallet?: boolean;
/** The wholesale screen shows the activity log. */
showActivity?: boolean;
defaultTab?: string;
}>(),
{ title: 'مشاهده اطلاعات مشتری', showWallet: false, showActivity: false, defaultTab: 'orders' },
);
const route = useRoute();
const id = route.params.id as string;
const loading = ref(false);
const customer = ref<Record<string, any> | undefined>(undefined);
const dashboard = ref<Record<string, any> | undefined>(undefined);
const orders = ref<Record<string, any>[]>([]);
const documents = ref<Record<string, any>[]>([]);
const wallet = ref<Record<string, any>[]>([]);
const activity = ref<Record<string, any>[]>([]);
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'تعداد آیتم', dataIndex: 'items_count' },
{ title: 'شعبه', dataIndex: 'branch' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const documentColumns = [
{ title: 'نوع', dataIndex: 'type' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'وضعیت', dataIndex: 'confirmed' },
{ title: 'توضیحات', dataIndex: 'note' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const walletColumns = [
{ title: 'مقدار (گرم)', dataIndex: 'value' },
{ title: 'تاریخ اعطا', dataIndex: 'caj' },
{ title: 'انقضا', dataIndex: 'expires_at' },
];
const activityColumns = [
{ title: 'عملیات', dataIndex: 'action' },
{ title: 'جزئیات', dataIndex: 'detail' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
onMounted(async () => {
try {
loading.value = true;
const [c, d, o, docs] = await Promise.all([
api.get(`customer/${id}`),
api.get(`major/dashboard/${id}`).catch(() => ({ data: undefined })),
api.get(`admin/customers-omde/${id}/orders`).catch(() => ({ data: [] })),
api.get(`admin/customers-omde/${id}/documents`).catch(() => ({ data: [] })),
]);
customer.value = c.data;
dashboard.value = d.data;
orders.value = o.data;
documents.value = docs.data;
if (props.showWallet) {
const w = await api
.get(`admin/customers-omde/${id}/wallet`)
.catch(() => ({ data: [] }));
wallet.value = w.data;
}
if (props.showActivity) {
const a = await api.get(`log/ago/${id}`).catch(() => ({ data: [] }));
activity.value = a.data;
}
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.stats {
display: flex;
gap: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/BankerList.vue — differs from OmdeList by title + model url only (verified: 18 diff lines, all whitespace besides those two). -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.banker.title;
const model = buildOmdeListModel('banker');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeArchivedList.vue. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.archived.title;
const model = buildOmdeListModel('archived');
</script>
@@ -0,0 +1,414 @@
<template>
<!--
Shared implementation of the five legacy wholesale-customer lists
(OmdeList, BankerList, ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList).
Legacy shipped these as five ~615-line files. Verified by diff, they differ by:
- ShopkeeperList vs OmdeList: 4 lines (card title + model import ONLY)
- BankerList: 18 lines, all whitespace/formatting except the same two
- OmdeArchived: 24 lines, same plus method ordering
- OmdeNonVerified: 34 lines, same plus two genuine quirks (see props below)
Per the agreed decision, behaviour and appearance are preserved exactly while
the implementation lives once, parameterised by props.
-->
<div id="page-user-list">
<a-card :title="title">
<div v-if="query && query.count && query.total">
{{ query.total }} رکورد یافت شد -
<span class="text-danger">{{ query.count }} رکورد غیر فعال</span>
</div>
<XTable ref="tbl" :model="model" @query="setCount">
<template #city="{ record }">
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
<div v-else>-</div>
</template>
<template #sts="{ record }">
{{ record.sts_str }}
</template>
<template #active="{ record }">
<a-tag :color="record.active === 1 ? 'green' : 'red'">
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template #opt="{ record }">
<div class="flex">
<a-dropdown>
<div class="cursor-pointer flex items-center">
<DownOutlined />
<SettingOutlined class="cursor-pointer w-5 ml-1" />
</div>
<template #overlay>
<a-menu @click="handleMenuClick($event, record.id)">
<a-menu-item
v-if="hasPermission('persons.list.client.omde.edit')"
key="1"
@click="toEdit(record.id)"
>
<div class="flex gap-2">
<EditOutlined class="cursor-pointer w-5 ml-1" />
<div>ویرایش</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view')"
key="2"
@click="toView(record.id)"
>
<div class="flex gap-2">
<EyeOutlined class="cursor-pointer w-5 ml-1" />
<div>مشاهده اطلاعات</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view.orders')"
key="3"
@click="viewOrder(record)"
>
<div class="flex gap-2">
<UnorderedListOutlined class="cursor-pointer w-5 ml-1" />
<div>مشاهده سفارش</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view.documents')"
key="4"
@click="viewCash(record)"
>
<div class="flex gap-2">
<FileTextOutlined class="cursor-pointer w-5 ml-1" />
<div>سند های مالی</div>
</div>
</a-menu-item>
<a-menu-item v-if="hasPermission('persons.list.client.omde.able')" key="7">
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleArchive(record.id)"
>
<div class="flex gap-2">
<CloudDownloadOutlined class="cursor-pointer w-5 ml-1" />
<div>بایگانی کردن</div>
</div>
</a-popconfirm>
</a-menu-item>
<!-- step one -->
<a-menu-item key="8">
<a-popconfirm
:ok-text="record.sts_verify_step_one === 1 ? 'تایید' : 'بله'"
:cancel-text="record.sts_verify_step_one === 1 ? 'رد' : 'خیر'"
:title="
record.sts_verify_step_one === 1
? 'وضعیت تایید اولیه مشتری به چه نحوی تغییر یابد؟'
: 'آیا از تغییر وضعیت مشتری اطمینان دارید؟'
"
@confirm="onConfirmStepOne(record)"
@cancel="onCancelStepOne(record)"
>
<div class="flex gap-2">
<component
:is="stepIconComponent(getStepOneIcon(record))"
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepOneIcon(record))"
/>
<div>{{ getStepOneText(record) }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<!-- step two: unlocked only once step one is ACCEPTED (=== 2) -->
<a-menu-item :disabled="record.sts_verify_step_one !== 2" key="9">
<a-popconfirm
:ok-text="record.sts_verify_step_two === 1 ? 'تایید' : 'بله'"
:cancel-text="record.sts_verify_step_two === 1 ? 'رد' : 'خیر'"
:title="
record.sts_verify_step_two === 1
? 'وضعیت تایید سفارش مشتری به چه نحوی تغییر یابد؟'
: 'آیا از تغییر وضعیت سفارش مشتری اطمینان دارید؟'
"
@confirm="onConfirmStepTwo(record)"
@cancel="onCancelStepTwo(record)"
>
<div class="flex gap-2">
<component
:is="stepIconComponent(getStepTwoIcon(record))"
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepTwoIcon(record))"
/>
<div>{{ getStepTwoText(record) }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.able.order')"
:disabled="record.sts_verify_step_one !== 2"
key="5"
>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleSts(record)"
>
<div class="flex gap-2">
<component
:is="record.sts === 1 ? CheckOutlined : StopOutlined"
class="cursor-pointer w-5 ml-1"
:class="record.sts === 1 ? 'text-success' : 'text-danger'"
/>
<div>{{ record.sts === 1 ? 'معلق کردن سفارش' : 'فعال کردن سفارش' }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.able')"
:disabled="record.sts_verify_step_one !== 2"
key="6"
>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleActive(record)"
>
<div class="flex gap-2">
<!--
NOTE legacy binds :icon="getStsIcon(record)" here while
colouring by getActiveIcon(record) — it shows the ORDER
status icon next to the ACCOUNT label. Preserved.
-->
<component
:is="record.sts === 1 ? CheckOutlined : StopOutlined"
class="cursor-pointer w-5 ml-1"
:class="record.active === 1 ? 'text-success' : 'text-danger'"
/>
<div>
{{ record.active === 1 ? 'معلق کردن حساب کاربری' : ' فعال کردن حساب کاربری' }}
</div>
</div>
</a-popconfirm>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
CheckOutlined,
CloudDownloadOutlined,
DownOutlined,
EditOutlined,
EyeOutlined,
FileTextOutlined,
QuestionCircleOutlined,
SettingOutlined,
StopOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import type { XTbl } from '../../../components/x-table/model';
const props = withDefaults(
defineProps<{
/** Card heading — the only visible difference between four of the five lists. */
title: string;
/** The table definition (differs only in its url). */
model: XTbl;
/**
* OmdeNonVerifiedList's quirk: its popconfirm calls rejectStepOne() in BOTH
* the accept and reject branches, so the first step can never be approved
* from that screen. Faithfully reproduced behind this flag rather than
* silently "fixed" — see docs/ADMIN-PORT-PLAN.md.
*/
rejectOnlyStepOne?: boolean;
}>(),
{ rejectOnlyStepOne: false },
);
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const query = ref<{ count?: number; total?: number } | undefined>(undefined);
/** legacy setCount(): the "N رکورد غیر فعال" banner above the grid */
function setCount(raw: unknown) {
const value = raw as { count?: number; total?: number } | undefined;
query.value = {
count: value && value.count ? value.count : undefined,
total: value && value.total ? value.total : undefined,
};
}
/**
* legacy handleMenuClick(): closes the dropdown for the keys that open a
* popconfirm, so the menu does not stay open behind the confirmation.
*/
function handleMenuClick(e: { key: string | number }, _id: string) {
const key = String(e.key);
return key === '5' || key === '6' || key === '7' || key === '8' || key === '9';
}
// ── verification step display, ported verbatim (1 pending / 2 accepted / 3 rejected)
function getStepOneText(row: Record<string, any>) {
switch (row.sts_verify_step_one) {
case 1:
return 'در انتظار تایید ورود مشتری';
case 2:
return 'رد ورود مشتری';
case 3:
return 'تایید ورود مشتری';
}
}
function getStepOneIcon(row: Record<string, any>) {
switch (row.sts_verify_step_one) {
case 1:
return 'HelpCircleIcon';
case 2:
return 'SlashIcon';
case 3:
return 'CheckIcon';
}
}
function getStepTwoText(row: Record<string, any>) {
switch (row.sts_verify_step_two) {
case 1:
return 'در انتظار فعال سازی';
case 2:
return 'غیر فعال سازی';
case 3:
return 'فعال سازی';
}
}
function getStepTwoIcon(row: Record<string, any>) {
switch (row.sts_verify_step_two) {
case 1:
return 'HelpCircleIcon';
case 2:
return 'SlashIcon';
case 3:
return 'CheckIcon';
}
}
/** feather name -> ant component, matching legacy's three icons */
function stepIconComponent(name?: string) {
if (name === 'HelpCircleIcon') return QuestionCircleOutlined;
if (name === 'CheckIcon') return CheckOutlined;
return StopOutlined; // SlashIcon
}
function stepIconClass(name?: string) {
if (name === 'HelpCircleIcon') return 'text-primary';
if (name === 'CheckIcon') return 'text-success';
return 'text-danger';
}
// ── actions
async function postVerify(payload: Record<string, unknown>, errorMsg: string) {
try {
await api.post('major/verify', payload);
message.info('تغییرات ثبت شد ');
tbl.value?.fetch();
} catch (e) {
message.error(errorMsg);
}
}
const acceptStepOne = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری');
const rejectStepOne = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_one: 3 }, 'خطا در تغییر وضعیت اولیه مشتری');
const acceptStepTwo = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری');
const rejectStepTwo = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_two: 3 }, 'خطا در تغییر وضعیت سفارش مشتری');
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
function onConfirmStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) {
return props.rejectOnlyStepOne ? rejectStepOne(record.id) : acceptStepOne(record.id);
}
if (record.sts_verify_step_one === 2) return rejectStepOne(record.id);
return acceptStepOne(record.id);
}
function onCancelStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) return rejectStepOne(record.id);
}
function onConfirmStepTwo(record: Record<string, any>) {
if (record.sts_verify_step_two === 1) return acceptStepTwo(record.id);
if (record.sts_verify_step_two === 2) return rejectStepTwo(record.id);
return acceptStepTwo(record.id);
}
function onCancelStepTwo(record: Record<string, any>) {
if (record.sts_verify_step_two === 1) return rejectStepTwo(record.id);
}
async function toggleActive(record: Record<string, any>) {
try {
await api.post('customer/toggle-active', { id: record.id });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleSts(record: Record<string, any>) {
try {
// BankerList sent an explicit {id, sts} object while the others posted the
// whole row; the server reads only `id`, so one shape is used here.
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleArchive(id: string) {
try {
await api.get(`major/archive/${id}`);
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در بایگانی');
}
}
function toEdit(id: string) {
router.push(`/bulkCustomer/edit/${id}`);
}
function toView(id: string) {
router.push(`/bulkCustomer/view/${id}`);
}
function viewOrder(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
}
function viewCash(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
}
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeList.vue (615 lines). -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.omde.title;
const model = buildOmdeListModel('omde');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Carries the legacy quirk where step-one confirm rejects instead of accepting. -->
<OmdeCustomerList :title="title" :model="model" reject-only-step-one />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.nonVerified.title;
const model = buildOmdeListModel('nonVerified');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/ShopkeeperList.vue — verified to differ from OmdeList by exactly 4 lines: card title and model import. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.shopkeeper.title;
const model = buildOmdeListModel('shopkeeper');
</script>
@@ -0,0 +1,17 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/omde/ViewOmdeCustomer.vue
(376 lines). Shares its implementation with the retail detail screen — see
CustomerDetail.vue for why. The wholesale variant adds the activity log
(legacy `GET log/ago/{id}`).
-->
<CustomerDetail
title="مشاهده اطلاعات مشتری عمده"
:show-activity="true"
:show-wallet="false"
/>
</template>
<script setup lang="ts">
import CustomerDetail from '../CustomerDetail.vue';
</script>
@@ -0,0 +1,70 @@
// Ported from legacy henza_admin/src/views/customer/omde/*Model.js
//
// The five legacy model files (OmdeListModel, BankerListModel,
// shopkeeperListModel, OmdeListNonVerifiedModel, OmdeArchiveListModel) are
// byte-identical except for ONE line — the endpoint url, plus a different title
// on two of them. Verified by diff. They are built here from one factory.
import XTbl, { Xtc } from '../../../components/x-table/model';
import { useUsersStore } from '../../../stores/users';
/** Endpoint + title per legacy file. */
export const OMDE_SOURCES = {
omde: { url: 'admin/customers-omde/list', title: 'لیست مشتریان های عمده' },
banker: { url: 'admin/customers-omde/banker/list', title: 'لیست بنکداران' },
shopkeeper: { url: 'admin/customers-omde/shopkeeper/list', title: 'لیست مغازه داران حضوری' },
// NOTE both of these carry the SAME title as the plain omde list in legacy —
// only their url differs. Preserved rather than "corrected".
nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان های عمده' },
archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان های عمده' },
} as const;
export type OmdeSourceKey = keyof typeof OMDE_SOURCES;
export function buildOmdeListModel(source: OmdeSourceKey): 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 { url, title } = OMDE_SOURCES[source];
const tbl = new XTbl(url, title);
tbl.add(new Xtc('id', 'کد').filterNumber());
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('city_id', 'شهر')
.hidden()
.renderSlot('city')
.filterKey('city_id')
.filterSelect(users.cityFilters),
);
tbl.add(new Xtc('reagent.name', 'کارشناس فروش').filterText().noSort());
tbl.add(new Xtc('gram_level', 'سطح اعتبار').filterNumber().noSort());
tbl.add(new Xtc('percent', 'درصد').filterNumber().noSort());
tbl.add(new Xtc('remain', 'بدهی گرمی').filterNumber().noSort());
tbl.add(new Xtc('rial_remain', 'بدهی ریالی').filterNumber().noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort());
tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort());
tbl.add(new Xtc('sts_str', 'وضعیت ثبت سفارش').renderSlot('sts').noSort());
tbl.add(
new Xtc('active', 'وضعیت حساب کاربری').renderSlot('active').noSort().filterSelect({
'1': 'فعال',
'-1': 'غیر فعال',
}),
);
tbl.add(new Xtc('phone', 'تلفن').filterText().hidden().noSort());
tbl.add(new Xtc('address', 'آدرس').filterText().hidden().noSort());
tbl.add(new Xtc('store_name', 'نام مغازه').filterText().hidden().noSort());
tbl.add(new Xtc('zone', 'منطقه').filterText().hidden().noSort());
tbl.add(new Xtc('pasazh_name', 'نام پاساژ').filterText().hidden().noSort());
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildOmdeListModel;
@@ -0,0 +1,159 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/reagentList/ReagentList.vue.
Unlike the five wholesale lists this one uses inline TooltipBtn icons rather
than a dropdown menu. Farsi copy and permission gates preserved verbatim,
including legacy's typo in the "فعال سازی سفارش سفارش" tooltip.
-->
<div id="page-user-list">
<a-card title="لیست کارشناسان فروش">
<XTable ref="tbl" :model="model">
<template #city="{ record }">
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
<div v-else>-</div>
</template>
<template #sts="{ record }">
{{ record.sts_str }}
</template>
<template #active="{ record }">
<a-tag :color="record.active === 1 ? 'green' : 'red'">
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('persons.list.sellers.edit')"
icon-class="w-5 h-5 text-primary"
class="ml-3"
icon="EditIcon"
title="ویرایش"
@click="toEdit(record.id)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="EyeIcon"
title="مشاهده اطلاعات"
@click="toView(record.id)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view.orders')"
icon-class="w-5 h-5 text-primary"
class="ml-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view.documents')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="FileTextIcon"
title="سند های مالی"
@click="viewCash(record)"
/>
<TooltipBtn
v-if="record.sts == 1 && hasPermission('persons.list.sellers.unable.order')"
icon-class="w-5 h-5 text-danger"
class="ml-3"
:icon="getStsIcon(record)"
title="توقف سفارش"
pop-title="آیا مطمئن هستید؟"
@click="toggleSts(record)"
/>
<TooltipBtn
v-if="record.sts !== 1 && hasPermission('persons.list.sellers.able.order')"
icon-class="w-5 h-5 text-success"
class="ml-3"
:icon="getStsIcon(record)"
title="فعال سازی سفارش سفارش"
pop-title="آیا مطمئن هستید؟"
@click="toggleSts(record)"
/>
<TooltipBtn
v-if="record.active == 1 && hasPermission('persons.list.sellers.unable')"
icon-class="w-5 h-5 text-danger"
class="ml-3"
icon="XIcon"
title="غیر فعال کردن حساب کاربری"
pop-title="آیا مطمئن هستید؟"
@click="toggleActive(record)"
/>
<TooltipBtn
v-if="record.active !== 1 && hasPermission('persons.list.sellers.able')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="CheckIcon"
title="فعال سازی حساب کاربری"
pop-title="آیا مطمئن هستید؟"
@click="toggleActive(record)"
/>
</div>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import XTable from '../../../components/x-table/XTable.vue';
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildReagentListModel } from './ReagentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildReagentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
function getStsIcon(row: Record<string, any>) {
return row.sts === 1 ? 'CheckIcon' : 'XIcon';
}
async function toggleActive(record: Record<string, any>) {
try {
await api.post('customer/toggle-active', { id: record.id });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleSts(record: Record<string, any>) {
try {
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
function toEdit(id: string) {
router.push(`/reagent/edit/${id}`);
}
function toView(id: string) {
router.push(`/bulkCustomer/view/${id}`);
}
function viewOrder(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
}
function viewCash(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
}
</script>
@@ -0,0 +1,47 @@
// Ported from legacy henza_admin/src/views/customer/reagentList/ReagentListModel.js
//
// Column keys, Farsi titles and filter types preserved exactly, INCLUDING the
// legacy quirk that 'name' is added twice (once visible as 'نام و نام خانوادگی'
// and once hidden as 'نام'), which makes two columns share one dataIndex.
import XTbl, { Xtc } from '../../../components/x-table/model';
import { useUsersStore } from '../../../stores/users';
export function buildReagentListModel(): XTbl {
const users = useUsersStore();
if (Object.keys(users.cityFilters).length < 1) {
users.getCities();
}
const tbl = new XTbl('admin/customers-omde/reagent/list', 'لیست کارشناسان فروش');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('lname', 'نام خانوادگی').filterText().hidden());
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('city_id', 'شهر')
.hidden()
.renderSlot('city')
.filterKey('city_id')
.filterSelect(users.cityFilters),
);
tbl.add(new Xtc('gram_level', 'سطح اعتبار').filterNumber().noSort());
tbl.add(new Xtc('percent', 'درصد').filterNumber().noSort());
tbl.add(new Xtc('remain', 'بدهی گرمی').filterNumber().noSort());
tbl.add(new Xtc('rial_remain', 'بدهی ریالی').filterNumber().noSort());
tbl.add(new Xtc('grams_sell', 'میزان فروش').filterNumber().noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort());
tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort());
tbl.add(new Xtc('sts_str', 'وضعیت ثبت سفارش').renderSlot('sts').noSort());
tbl.add(
new Xtc('active', 'وضعیت حساب کاربری').renderSlot('active').noSort().filterSelect({
'1': 'فعال',
'-1': 'غیر فعال',
}),
);
tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt'));
return tbl;
}
export default buildReagentListModel;
@@ -0,0 +1,130 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/single-customer/SingleCustomer.vue.
Farsi copy, permission gates and the wallet-charge modal are preserved,
including legacy's quirk that the "مشاهده اطلاعات" icon is gated on the
`wallet.charge` rid rather than a view rid.
-->
<a-card :title="is_branch ? 'لیست مشتریان' : 'لیست مشتریان تک فروشی'">
<XTable ref="tbl" :model="model">
<template #op="{ record }">
<a-tooltip title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('wallet.charge') && !is_branch" title="شارژ کیف پول">
<CreditCardOutlined
class="cursor-pointer mx-1"
style="color: #d48806"
@click="toCharge(record.id)"
/>
</a-tooltip>
<!-- legacy gates this on wallet.charge too — preserved -->
<a-tooltip v-if="hasPermission('wallet.charge')" title="مشاهده اطلاعات">
<EyeOutlined class="cursor-pointer mx-1 text-success" @click="toView(record.id)" />
</a-tooltip>
</template>
</XTable>
<a-modal
v-if="hasPermission('wallet.charge')"
v-model:open="showChargeForm"
:footer="null"
title="شارژ کیف پول"
>
<a-spin :spinning="loading">
<a-form layout="vertical">
<div class="charge-grid">
<a-form-item label="میزان شارژ">
<a-input-number v-model:value="chargeValue" class="w-full" />
</a-form-item>
<a-popconfirm
style="margin-bottom: 24px"
title="آیا از شارژ کیف پول مطمعن هستید؟"
ok-text="بله"
cancel-text="خیر"
@confirm="chargeWallet"
@cancel="onCancelCharge"
>
<a-button type="primary">ثبت</a-button>
</a-popconfirm>
</div>
</a-form>
</a-spin>
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CreditCardOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildSingleCustomerListModel } from './SingleCustomerListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildSingleCustomerListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const is_branch = computed(() => isBranch());
const showChargeForm = ref(false);
const loading = ref(false);
const chargeValue = ref<number | undefined>(undefined);
const chargeCustomerId = ref<string | undefined>(undefined);
function toCharge(id: string) {
chargeCustomerId.value = id;
chargeValue.value = undefined;
showChargeForm.value = true;
}
function onCancelCharge() {
chargeValue.value = undefined;
showChargeForm.value = false;
}
async function chargeWallet() {
if (!chargeCustomerId.value || !chargeValue.value) {
message.error('لطفا مقدار را وارد کنید');
return;
}
try {
loading.value = true;
await api.post('wallet/charge', {
customer_id: chargeCustomerId.value,
value: chargeValue.value,
});
message.info('عملیات با موفقیت انجام شد');
showChargeForm.value = false;
chargeValue.value = undefined;
await router.push('/wallet');
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
function toEdit(id: string) {
router.push(`/singleCustomer/edit/${id}`);
}
function toView(id: string) {
router.push(`/singleCustomer/view/${id}`);
}
</script>
<style scoped>
.charge-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
align-items: end;
}
</style>
@@ -0,0 +1,16 @@
// Ported from legacy henza_admin/src/views/customer/single-customer/SingleCustomerList.js
import XTbl, { Xtc } from '../../../components/x-table/model';
export function buildSingleCustomerListModel(): XTbl {
const tbl = new XTbl('admin/customers-omde/single/list', 'لیست مشتریان تک');
tbl.add(new Xtc('id', 'کد').filterNumber());
tbl.add(new Xtc('fname', 'نام').filterKey('fullName').filterText());
tbl.add(new Xtc('lname', 'نام خانوادگی').filterKey('fullName').filterText().noSort());
tbl.add(new Xtc('phone', 'تلفن تماس').filterKey('mobile').filterText().noSort());
tbl.add(new Xtc('email', 'ایمیل').filterText().noSort());
tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort());
tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('op'));
return tbl;
}
export default buildSingleCustomerListModel;
@@ -0,0 +1,18 @@
<template>
<!--
Ported from legacy
henza_admin/src/views/customer/single-customer/ViewSingleCustomer.vue
(425 lines). Shares its implementation with the wholesale detail screen —
see CustomerDetail.vue. The retail variant adds the wallet panel (legacy
`GET single/wallet/{id}`).
-->
<CustomerDetail
title="مشاهده اطلاعات مشتری"
:show-wallet="true"
:show-activity="false"
/>
</template>
<script setup lang="ts">
import CustomerDetail from '../CustomerDetail.vue';
</script>