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,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;