feat: 迁移 CRM 数据统计与业绩目标设置
- 迁移 CRM 销售漏斗分析,补充漏斗图、阶段统计和商机转化率分析 - 迁移 CRM 员工业绩分析,补充合同汇总统计 - 迁移 CRM 产品分析,新增产品销售情况和产品分类销售分析 - 新增 CRM 业绩达成统计,支持销售目标和回款目标达成情况展示 - 新增 CRM 业绩目标设置,支持部门和员工维度的目标配置 - 补充 web-antd、web-antdv-next、web-ele 对应 API 和页面实现 - 调整统计图表配置、筛选项和表格展示,统一多端 CRM 统计交互
This commit is contained in:
67
apps/web-antd/src/api/crm/performance/config/index.ts
Normal file
67
apps/web-antd/src/api/crm/performance/config/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace CrmPerformanceConfigApi {
|
||||
/** 业绩目标设置 */
|
||||
export interface PerformanceConfig {
|
||||
id?: number;
|
||||
objectId: number;
|
||||
objectName?: string;
|
||||
objectType: number;
|
||||
year: number | string;
|
||||
januaryTargetPrice?: number;
|
||||
februaryTargetPrice?: number;
|
||||
marchTargetPrice?: number;
|
||||
aprilTargetPrice?: number;
|
||||
mayTargetPrice?: number;
|
||||
juneTargetPrice?: number;
|
||||
julyTargetPrice?: number;
|
||||
augustTargetPrice?: number;
|
||||
septemberTargetPrice?: number;
|
||||
octoberTargetPrice?: number;
|
||||
novemberTargetPrice?: number;
|
||||
decemberTargetPrice?: number;
|
||||
bizType: number;
|
||||
yearTargetPrice?: number;
|
||||
createTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
export enum PerformanceConfigObjectTypeEnum {
|
||||
DEPT = 2,
|
||||
USER = 3,
|
||||
}
|
||||
|
||||
/** 查询业绩目标设置分页 */
|
||||
export function getPerformanceConfigPage(params: PageParam) {
|
||||
return requestClient.get<
|
||||
PageResult<CrmPerformanceConfigApi.PerformanceConfig>
|
||||
>('/crm/performance-config/page', { params });
|
||||
}
|
||||
|
||||
/** 获得业绩目标设置详情 */
|
||||
export function getPerformanceConfig(id: number) {
|
||||
return requestClient.get<CrmPerformanceConfigApi.PerformanceConfig>(
|
||||
`/crm/performance-config/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增业绩目标设置 */
|
||||
export function createPerformanceConfig(
|
||||
data: CrmPerformanceConfigApi.PerformanceConfig,
|
||||
) {
|
||||
return requestClient.post('/crm/performance-config/create', data);
|
||||
}
|
||||
|
||||
/** 修改业绩目标设置 */
|
||||
export function updatePerformanceConfig(
|
||||
data: CrmPerformanceConfigApi.PerformanceConfig,
|
||||
) {
|
||||
return requestClient.put('/crm/performance-config/update', data);
|
||||
}
|
||||
|
||||
/** 删除业绩目标设置 */
|
||||
export function deletePerformanceConfig(id: number) {
|
||||
return requestClient.delete(`/crm/performance-config/delete?id=${id}`);
|
||||
}
|
||||
30
apps/web-antd/src/api/crm/statistics/performanceTarget.ts
Normal file
30
apps/web-antd/src/api/crm/statistics/performanceTarget.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace CrmStatisticsPerformanceTargetApi {
|
||||
/** 业绩目标完成情况请求 */
|
||||
export interface PerformanceTargetReqVO {
|
||||
deptId: number;
|
||||
userId?: number;
|
||||
year: number;
|
||||
bizType: number;
|
||||
}
|
||||
|
||||
/** 业绩目标完成情况响应 */
|
||||
export interface PerformanceTargetRespVO {
|
||||
month: number;
|
||||
targetPrice: number;
|
||||
currentPrice: number;
|
||||
completionRate: number;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获得业绩目标完成情况 */
|
||||
export function getPerformanceTargetSummary(
|
||||
params: CrmStatisticsPerformanceTargetApi.PerformanceTargetReqVO,
|
||||
) {
|
||||
return requestClient.get<
|
||||
CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO[]
|
||||
>('/crm/statistics-performance-target/get-performance-target-summary', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
46
apps/web-antd/src/api/crm/statistics/product.ts
Normal file
46
apps/web-antd/src/api/crm/statistics/product.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace CrmStatisticsProductApi {
|
||||
/** 产品销售情况统计响应 */
|
||||
export interface ProductSalesRespVO {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
contractId: number;
|
||||
contractNo: string;
|
||||
contractName: string;
|
||||
ownerUserId: number;
|
||||
ownerUserName: string;
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
productPrice: number;
|
||||
productCount: number;
|
||||
productTotalPrice: number;
|
||||
}
|
||||
|
||||
/** 产品分类销售分析响应 */
|
||||
export interface ProductCategoryRespVO {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
contractCount: number;
|
||||
productCount: number;
|
||||
productTotalPrice: number;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获得产品销售情况统计 */
|
||||
export function getProductSalesList(params: any) {
|
||||
return requestClient.get<CrmStatisticsProductApi.ProductSalesRespVO[]>(
|
||||
'/crm/statistics-product/get-product-sales-list',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 获得产品分类销售分析 */
|
||||
export function getProductCategorySummary(params: any) {
|
||||
return requestClient.get<CrmStatisticsProductApi.ProductCategoryRespVO[]>(
|
||||
'/crm/statistics-product/get-product-category-summary',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
263
apps/web-antd/src/views/crm/performance/config/data.ts
Normal file
263
apps/web-antd/src/views/crm/performance/config/data.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { PerformanceConfigObjectTypeEnum } from '#/api/crm/performance/config';
|
||||
import { BizTypeEnum } from '#/api/crm/permission';
|
||||
import { getSimpleDeptList } from '#/api/system/dept';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
|
||||
export const bizTypeOptions = [
|
||||
{ label: '销售目标', value: BizTypeEnum.CRM_CONTRACT },
|
||||
{ label: '回款目标', value: BizTypeEnum.CRM_RECEIVABLE },
|
||||
];
|
||||
|
||||
export const objectTypeOptions = [
|
||||
{ label: '部门', value: PerformanceConfigObjectTypeEnum.DEPT },
|
||||
{ label: '员工', value: PerformanceConfigObjectTypeEnum.USER },
|
||||
];
|
||||
|
||||
export const monthFields = [
|
||||
{ label: '一月', prop: 'januaryTargetPrice' },
|
||||
{ label: '二月', prop: 'februaryTargetPrice' },
|
||||
{ label: '三月', prop: 'marchTargetPrice' },
|
||||
{ label: '四月', prop: 'aprilTargetPrice' },
|
||||
{ label: '五月', prop: 'mayTargetPrice' },
|
||||
{ label: '六月', prop: 'juneTargetPrice' },
|
||||
{ label: '七月', prop: 'julyTargetPrice' },
|
||||
{ label: '八月', prop: 'augustTargetPrice' },
|
||||
{ label: '九月', prop: 'septemberTargetPrice' },
|
||||
{ label: '十月', prop: 'octoberTargetPrice' },
|
||||
{ label: '十一月', prop: 'novemberTargetPrice' },
|
||||
{ label: '十二月', prop: 'decemberTargetPrice' },
|
||||
] as const;
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'year',
|
||||
label: '年份',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
picker: 'year',
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
placeholder: '请选择年份',
|
||||
},
|
||||
defaultValue: new Date().getFullYear().toString(),
|
||||
},
|
||||
{
|
||||
fieldName: 'bizType',
|
||||
label: '目标类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: bizTypeOptions,
|
||||
placeholder: '请选择目标类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'objectType',
|
||||
label: '对象类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: objectTypeOptions,
|
||||
placeholder: '请选择对象类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deptObjectId',
|
||||
label: '部门',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => handleTree(await getSimpleDeptList()),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
treeDefaultExpandAll: true,
|
||||
placeholder: '请选择部门',
|
||||
allowClear: true,
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['objectType'],
|
||||
show: (values) =>
|
||||
values.objectType === PerformanceConfigObjectTypeEnum.DEPT,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'userObjectId',
|
||||
label: '员工',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择员工',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['objectType'],
|
||||
show: (values) =>
|
||||
values.objectType === PerformanceConfigObjectTypeEnum.USER,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'year',
|
||||
label: '年份',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
picker: 'year',
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
placeholder: '请选择年份',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'bizType',
|
||||
label: '目标类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: bizTypeOptions,
|
||||
placeholder: '请选择目标类型',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'objectType',
|
||||
label: '对象类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: objectTypeOptions,
|
||||
placeholder: '请选择对象类型',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'deptObjectId',
|
||||
label: '目标对象',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => handleTree(await getSimpleDeptList()),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
treeCheckStrictly: true,
|
||||
treeDefaultExpandAll: true,
|
||||
placeholder: '请选择部门',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['objectType'],
|
||||
show: (values) =>
|
||||
values.objectType === PerformanceConfigObjectTypeEnum.DEPT,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'userObjectId',
|
||||
label: '目标对象',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择员工',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['objectType'],
|
||||
show: (values) =>
|
||||
values.objectType === PerformanceConfigObjectTypeEnum.USER,
|
||||
},
|
||||
},
|
||||
...monthFields.map((item) => ({
|
||||
fieldName: item.prop,
|
||||
label: item.label,
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: '!w-full',
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 1000,
|
||||
},
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ field: 'year', title: '年份', width: 90, fixed: 'left' },
|
||||
{
|
||||
field: 'objectType',
|
||||
title: '对象类型',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
slots: { default: 'objectType' },
|
||||
},
|
||||
{
|
||||
field: 'objectName',
|
||||
title: '目标对象',
|
||||
minWidth: 140,
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
field: 'bizType',
|
||||
title: '目标类型',
|
||||
width: 100,
|
||||
slots: { default: 'bizType' },
|
||||
},
|
||||
...monthFields.map((item) => ({
|
||||
field: item.prop,
|
||||
title: item.label,
|
||||
formatter: 'formatAmount2',
|
||||
width: 120,
|
||||
})),
|
||||
{
|
||||
field: 'yearTargetPrice',
|
||||
title: '年度目标',
|
||||
formatter: 'formatAmount2',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
formatter: 'formatDateTime',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 获取目标类型名称 */
|
||||
export function getBizTypeLabel(value: number) {
|
||||
return bizTypeOptions.find((item) => item.value === value)?.label || '';
|
||||
}
|
||||
|
||||
/** 获取对象类型名称 */
|
||||
export function getObjectTypeLabel(value: number) {
|
||||
return objectTypeOptions.find((item) => item.value === value)?.label || '';
|
||||
}
|
||||
150
apps/web-antd/src/views/crm/performance/config/index.vue
Normal file
150
apps/web-antd/src/views/crm/performance/config/index.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { CrmPerformanceConfigApi } from '#/api/crm/performance/config';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deletePerformanceConfig,
|
||||
getPerformanceConfigPage,
|
||||
PerformanceConfigObjectTypeEnum,
|
||||
} from '#/api/crm/performance/config';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import {
|
||||
getBizTypeLabel,
|
||||
getObjectTypeLabel,
|
||||
useGridColumns,
|
||||
useGridFormSchema,
|
||||
} from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建业绩目标 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑业绩目标 */
|
||||
function handleEdit(row: CrmPerformanceConfigApi.PerformanceConfig) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除业绩目标 */
|
||||
async function handleDelete(row: CrmPerformanceConfigApi.PerformanceConfig) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', ['业绩目标']),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deletePerformanceConfig(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', ['业绩目标']));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const objectId =
|
||||
formValues.objectType === PerformanceConfigObjectTypeEnum.DEPT
|
||||
? formValues.deptObjectId
|
||||
: formValues.objectType === PerformanceConfigObjectTypeEnum.USER
|
||||
? formValues.userObjectId
|
||||
: undefined;
|
||||
return await getPerformanceConfigPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
deptObjectId: undefined,
|
||||
objectId,
|
||||
userObjectId: undefined,
|
||||
year: formValues.year ? Number(formValues.year) : undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<CrmPerformanceConfigApi.PerformanceConfig>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid table-title="业绩目标设置">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['业绩目标']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['crm:performance-config:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #objectType="{ row }">
|
||||
{{ getObjectTypeLabel(row.objectType) }}
|
||||
</template>
|
||||
<template #bizType="{ row }">
|
||||
{{ getBizTypeLabel(row.bizType) }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['crm:performance-config:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['crm:performance-config:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', ['业绩目标']),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
132
apps/web-antd/src/views/crm/performance/config/modules/form.vue
Normal file
132
apps/web-antd/src/views/crm/performance/config/modules/form.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CrmPerformanceConfigApi } from '#/api/crm/performance/config';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createPerformanceConfig,
|
||||
getPerformanceConfig,
|
||||
PerformanceConfigObjectTypeEnum,
|
||||
updatePerformanceConfig,
|
||||
} from '#/api/crm/performance/config';
|
||||
import { BizTypeEnum } from '#/api/crm/permission';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { monthFields, useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<CrmPerformanceConfigApi.PerformanceConfig>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['业绩目标'])
|
||||
: $t('ui.actionTitle.create', ['业绩目标']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
labelWidth: 100,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
const data = buildSubmitData(await formApi.getValues());
|
||||
if (!data.objectId) {
|
||||
message.warning('请选择目标对象');
|
||||
modalApi.unlock();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await (data.id
|
||||
? updatePerformanceConfig(data)
|
||||
: createPerformanceConfig(data));
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
await formApi.resetForm();
|
||||
const data =
|
||||
modalApi.getData<CrmPerformanceConfigApi.PerformanceConfig>();
|
||||
if (!data?.id) {
|
||||
await formApi.setValues(buildFormValues());
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getPerformanceConfig(data.id);
|
||||
await formApi.setValues(buildFormValues(formData.value));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 构建表单默认值 */
|
||||
function buildFormValues(data?: CrmPerformanceConfigApi.PerformanceConfig) {
|
||||
const formValues: any = {
|
||||
objectId: undefined,
|
||||
objectType: PerformanceConfigObjectTypeEnum.DEPT,
|
||||
year: new Date().getFullYear().toString(),
|
||||
bizType: BizTypeEnum.CRM_CONTRACT,
|
||||
...Object.fromEntries(monthFields.map((item) => [item.prop, 0])),
|
||||
...data,
|
||||
};
|
||||
formValues.year = String(formValues.year);
|
||||
if (formValues.objectType === PerformanceConfigObjectTypeEnum.USER) {
|
||||
formValues.userObjectId = formValues.objectId;
|
||||
} else {
|
||||
formValues.deptObjectId = formValues.objectId;
|
||||
}
|
||||
return formValues;
|
||||
}
|
||||
|
||||
/** 构建提交数据 */
|
||||
function buildSubmitData(values: Record<string, any>) {
|
||||
const data = { ...values } as CrmPerformanceConfigApi.PerformanceConfig & {
|
||||
deptObjectId?: number;
|
||||
userObjectId?: number;
|
||||
};
|
||||
data.objectId =
|
||||
data.objectType === PerformanceConfigObjectTypeEnum.DEPT
|
||||
? data.deptObjectId!
|
||||
: data.userObjectId!;
|
||||
data.year = Number(data.year);
|
||||
monthFields.forEach((item) => {
|
||||
data[item.prop] = Number(data[item.prop] || 0);
|
||||
});
|
||||
delete data.deptObjectId;
|
||||
delete data.userObjectId;
|
||||
return data;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-3/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { CrmStatisticsPerformanceTargetApi } from '#/api/crm/statistics/performanceTarget';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { ContentWrap, Page } from '@vben/common-ui';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { erpPriceInputFormatter, handleTree } from '@vben/utils';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { BizTypeEnum } from '#/api/crm/permission';
|
||||
import { getPerformanceTargetSummary } from '#/api/crm/statistics/performanceTarget';
|
||||
import { getSimpleDeptList } from '#/api/system/dept';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const bizTypeOptions = [
|
||||
{ label: '销售目标', value: BizTypeEnum.CRM_CONTRACT },
|
||||
{ label: '回款目标', value: BizTypeEnum.CRM_RECEIVABLE },
|
||||
];
|
||||
|
||||
/** 搜索表单 */
|
||||
function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'year',
|
||||
label: '选择年份',
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
picker: 'year',
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
placeholder: '请选择年份',
|
||||
},
|
||||
defaultValue: new Date().getFullYear().toString(),
|
||||
},
|
||||
{
|
||||
fieldName: 'bizType',
|
||||
label: '目标类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: bizTypeOptions,
|
||||
placeholder: '请选择目标类型',
|
||||
},
|
||||
defaultValue: BizTypeEnum.CRM_CONTRACT,
|
||||
},
|
||||
{
|
||||
fieldName: 'deptId',
|
||||
label: '归属部门',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => handleTree(await getSimpleDeptList()),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
treeDefaultExpandAll: true,
|
||||
placeholder: '请选择归属部门',
|
||||
},
|
||||
defaultValue: userStore.userInfo?.deptId,
|
||||
},
|
||||
{
|
||||
fieldName: 'userId',
|
||||
label: '员工',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
allowClear: true,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择员工',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [QueryForm, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
schema: useGridFormSchema(),
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.query'),
|
||||
},
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2',
|
||||
handleSubmit: async () => {
|
||||
await loadData();
|
||||
},
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'monthName', title: '月份', minWidth: 120 },
|
||||
{
|
||||
field: 'targetPrice',
|
||||
title: '目标金额(元)',
|
||||
formatter: 'formatAmount2',
|
||||
minWidth: 160,
|
||||
slots: { default: 'targetPrice' },
|
||||
},
|
||||
{
|
||||
field: 'currentPrice',
|
||||
title: '完成金额(元)',
|
||||
formatter: 'formatAmount2',
|
||||
minWidth: 160,
|
||||
slots: { default: 'currentPrice' },
|
||||
},
|
||||
{ field: 'completionRateText', title: '完成率', minWidth: 120 },
|
||||
],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'month',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<
|
||||
CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO
|
||||
>,
|
||||
});
|
||||
|
||||
/** 获取接口参数 */
|
||||
async function getApiParams() {
|
||||
const values = await formApi.getValues();
|
||||
return {
|
||||
...values,
|
||||
year: Number(values.year),
|
||||
};
|
||||
}
|
||||
|
||||
/** 格式化月份 */
|
||||
function formatMonth(year: number, month: number) {
|
||||
return `${year}-${String(month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 构建图表配置 */
|
||||
function getChartOptions(
|
||||
year: number,
|
||||
data: CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO[],
|
||||
) {
|
||||
return {
|
||||
grid: {
|
||||
left: 20,
|
||||
right: 40,
|
||||
bottom: 72,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
bottom: 8,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
name: '月份',
|
||||
data: data.map((item) => formatMonth(year, item.month)),
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(元)',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '完成率',
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '目标金额(元)',
|
||||
type: 'bar',
|
||||
data: data.map((item) => item.targetPrice),
|
||||
},
|
||||
{
|
||||
name: '完成金额(元)',
|
||||
type: 'bar',
|
||||
data: data.map((item) => item.currentPrice),
|
||||
},
|
||||
{
|
||||
name: '完成率(%)',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: data.map((item) => item.completionRate),
|
||||
},
|
||||
],
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataZoom: {
|
||||
xAxisIndex: false,
|
||||
},
|
||||
brush: {
|
||||
type: ['lineX', 'clear'],
|
||||
},
|
||||
saveAsImage: { show: true, name: '业绩达成' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 统计合计行 */
|
||||
function buildSummaryRow(
|
||||
data: CrmStatisticsPerformanceTargetApi.PerformanceTargetRespVO[],
|
||||
) {
|
||||
const targetTotal = data.reduce(
|
||||
(sum, item) => sum + Number(item.targetPrice || 0),
|
||||
0,
|
||||
);
|
||||
const currentTotal = data.reduce(
|
||||
(sum, item) => sum + Number(item.currentPrice || 0),
|
||||
0,
|
||||
);
|
||||
const completionRate =
|
||||
targetTotal > 0 ? ((currentTotal / targetTotal) * 100).toFixed(2) : '0.00';
|
||||
return {
|
||||
month: 13,
|
||||
monthName: '合计',
|
||||
targetPrice: targetTotal,
|
||||
currentPrice: currentTotal,
|
||||
completionRateText: `${completionRate}%`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取业绩目标完成情况 */
|
||||
async function loadData() {
|
||||
const params = await getApiParams();
|
||||
const data = await getPerformanceTargetSummary(params);
|
||||
const tableData = data.map((item) => ({
|
||||
...item,
|
||||
monthName: formatMonth(params.year, item.month),
|
||||
completionRateText: `${item.completionRate || 0}%`,
|
||||
}));
|
||||
await renderEcharts(getChartOptions(params.year, data), true);
|
||||
await gridApi.grid.reloadData([...tableData, buildSummaryRow(data)]);
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<ContentWrap>
|
||||
<QueryForm />
|
||||
<EchartsUI class="mb-5 h-[500px] w-full" ref="chartRef" />
|
||||
<Grid>
|
||||
<template #targetPrice="{ row }">
|
||||
{{ erpPriceInputFormatter(row.targetPrice) }}
|
||||
</template>
|
||||
<template #currentPrice="{ row }">
|
||||
{{ erpPriceInputFormatter(row.currentPrice) }}
|
||||
</template>
|
||||
</Grid>
|
||||
</ContentWrap>
|
||||
</Page>
|
||||
</template>
|
||||
585
apps/web-antd/src/views/crm/statistics/product/index.vue
Normal file
585
apps/web-antd/src/views/crm/statistics/product/index.vue
Normal file
@@ -0,0 +1,585 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { CrmStatisticsProductApi } from '#/api/crm/statistics/product';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { ContentWrap, Page } from '@vben/common-ui';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import {
|
||||
beginOfDay,
|
||||
endOfDay,
|
||||
formatDateTime,
|
||||
handleTree,
|
||||
} from '@vben/utils';
|
||||
|
||||
import { Button, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getProductSimpleList } from '#/api/crm/product';
|
||||
import { getProductCategoryList } from '#/api/crm/product/category';
|
||||
import {
|
||||
getProductCategorySummary,
|
||||
getProductSalesList,
|
||||
} from '#/api/crm/statistics/product';
|
||||
import { getSimpleDeptList } from '#/api/system/dept';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { push } = useRouter();
|
||||
const activeTabName = ref('productSalesList');
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
enum ProductSalesRowTypeEnum {
|
||||
CATEGORY_SUMMARY = 'categorySummary',
|
||||
DETAIL = 'detail',
|
||||
PRODUCT_SUMMARY = 'productSummary',
|
||||
}
|
||||
|
||||
type ProductSalesRow =
|
||||
Partial<CrmStatisticsProductApi.ProductSalesRespVO> & {
|
||||
categoryRowspan?: number;
|
||||
index?: number;
|
||||
productRowspan?: number;
|
||||
rowKey?: string;
|
||||
rowType: ProductSalesRowTypeEnum;
|
||||
summaryLabel?: string;
|
||||
};
|
||||
|
||||
const SUMMARY_LABEL_COLUMN_INDEX = 0;
|
||||
const CATEGORY_COLUMN_INDEX = 1;
|
||||
const PRODUCT_COLUMN_INDEX = 2;
|
||||
const CONTRACT_NO_COLUMN_INDEX = 3;
|
||||
const CUSTOMER_COLUMN_INDEX = 6;
|
||||
const SUMMARY_VALUE_COLUMN_INDEX = 8;
|
||||
const LINK_COLUMN_INDEXES = [
|
||||
PRODUCT_COLUMN_INDEX,
|
||||
CONTRACT_NO_COLUMN_INDEX,
|
||||
CUSTOMER_COLUMN_INDEX,
|
||||
];
|
||||
|
||||
/** 搜索表单 */
|
||||
function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'times',
|
||||
label: '时间范围',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
...getRangePickerDefaultProps(),
|
||||
allowClear: false,
|
||||
},
|
||||
defaultValue: [
|
||||
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 30))),
|
||||
formatDateTime(endOfDay(new Date(Date.now() - 3600 * 1000 * 24))),
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldName: 'deptId',
|
||||
label: '归属部门',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => handleTree(await getSimpleDeptList()),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
treeDefaultExpandAll: true,
|
||||
placeholder: '请选择归属部门',
|
||||
},
|
||||
defaultValue: userStore.userInfo?.deptId,
|
||||
},
|
||||
{
|
||||
fieldName: 'userId',
|
||||
label: '员工',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
allowClear: true,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择员工',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'categoryId',
|
||||
label: '产品分类',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: async () => handleTree(await getProductCategoryList()),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
treeDefaultExpandAll: true,
|
||||
placeholder: '请选择产品分类',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getProductSimpleList,
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [QueryForm, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
schema: useGridFormSchema(),
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.query'),
|
||||
},
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
|
||||
handleSubmit: async () => {
|
||||
await loadData();
|
||||
},
|
||||
});
|
||||
|
||||
const [ProductSalesGrid, productSalesGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'index', title: '序号', width: 90, slots: { default: 'index' } },
|
||||
{ field: 'categoryName', title: '产品分类', minWidth: 140 },
|
||||
{
|
||||
field: 'productName',
|
||||
title: '产品名称',
|
||||
minWidth: 180,
|
||||
slots: { default: 'productName' },
|
||||
},
|
||||
{
|
||||
field: 'contractNo',
|
||||
title: '合同编号',
|
||||
minWidth: 160,
|
||||
slots: { default: 'contractNo' },
|
||||
},
|
||||
{ field: 'contractName', title: '合同名称', minWidth: 180 },
|
||||
{ field: 'ownerUserName', title: '负责人', minWidth: 120 },
|
||||
{
|
||||
field: 'customerName',
|
||||
title: '客户名称',
|
||||
minWidth: 180,
|
||||
slots: { default: 'customerName' },
|
||||
},
|
||||
{
|
||||
field: 'productPrice',
|
||||
title: '销售单价(元)',
|
||||
formatter: 'formatAmount2',
|
||||
minWidth: 140,
|
||||
},
|
||||
{ field: 'productCount', title: '数量', minWidth: 120 },
|
||||
{
|
||||
field: 'productTotalPrice',
|
||||
title: '订单产品小计(元)',
|
||||
formatter: 'formatAmount2',
|
||||
minWidth: 160,
|
||||
},
|
||||
],
|
||||
cellClassName: ({ columnIndex, row }: any) =>
|
||||
row.rowType === ProductSalesRowTypeEnum.DETAIL &&
|
||||
LINK_COLUMN_INDEXES.includes(columnIndex)
|
||||
? 'is-link-cell'
|
||||
: '',
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowClassName: ({ row }: any) => {
|
||||
if (row.rowType === ProductSalesRowTypeEnum.PRODUCT_SUMMARY) {
|
||||
return 'product-summary-row';
|
||||
}
|
||||
if (row.rowType === ProductSalesRowTypeEnum.CATEGORY_SUMMARY) {
|
||||
return 'category-summary-row';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'rowKey',
|
||||
isHover: true,
|
||||
},
|
||||
spanMethod,
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<ProductSalesRow>,
|
||||
});
|
||||
|
||||
const [ProductCategoryGrid, productCategoryGridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ type: 'seq', title: '序号', width: 80 },
|
||||
{ field: 'categoryName', title: '产品分类', minWidth: 180 },
|
||||
{ field: 'contractCount', title: '合同数量', minWidth: 120 },
|
||||
{ field: 'productCount', title: '销售数量', minWidth: 120 },
|
||||
{
|
||||
field: 'productTotalPrice',
|
||||
title: '销售金额(元)',
|
||||
formatter: 'formatAmount2',
|
||||
minWidth: 160,
|
||||
},
|
||||
],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'categoryId',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
} as VxeTableGridOptions<CrmStatisticsProductApi.ProductCategoryRespVO>,
|
||||
});
|
||||
|
||||
/** 转换为数值 */
|
||||
function getNumber(value?: number) {
|
||||
return Number(value || 0);
|
||||
}
|
||||
|
||||
/** 获得分类分组 Key */
|
||||
function getCategoryKey(item: CrmStatisticsProductApi.ProductSalesRespVO) {
|
||||
return item.categoryId || `category-${item.categoryName}`;
|
||||
}
|
||||
|
||||
/** 获得产品分组 Key */
|
||||
function getProductKey(item: CrmStatisticsProductApi.ProductSalesRespVO) {
|
||||
return item.productId;
|
||||
}
|
||||
|
||||
/** 构建产品小计行 */
|
||||
function buildProductSummaryRow(
|
||||
rows: CrmStatisticsProductApi.ProductSalesRespVO[],
|
||||
): ProductSalesRow {
|
||||
return {
|
||||
rowType: ProductSalesRowTypeEnum.PRODUCT_SUMMARY,
|
||||
rowKey: `product-summary-${rows[0]?.productId || rows[0]?.productName}`,
|
||||
summaryLabel: `${rows[0]?.productName || '产品'} 小计`,
|
||||
productCount: rows.reduce((sum, item) => sum + getNumber(item.productCount), 0),
|
||||
productTotalPrice: rows.reduce(
|
||||
(sum, item) => sum + getNumber(item.productTotalPrice),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建分类小计行 */
|
||||
function buildCategorySummaryRow(
|
||||
rows: CrmStatisticsProductApi.ProductSalesRespVO[],
|
||||
): ProductSalesRow {
|
||||
return {
|
||||
rowType: ProductSalesRowTypeEnum.CATEGORY_SUMMARY,
|
||||
rowKey: `category-summary-${rows[0]?.categoryId || rows[0]?.categoryName}`,
|
||||
summaryLabel: `${rows[0]?.categoryName || '未分类'} 小计`,
|
||||
productCount: rows.reduce((sum, item) => sum + getNumber(item.productCount), 0),
|
||||
productTotalPrice: rows.reduce(
|
||||
(sum, item) => sum + getNumber(item.productTotalPrice),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 按产品分组 */
|
||||
function buildProductGroups(rows: CrmStatisticsProductApi.ProductSalesRespVO[]) {
|
||||
const result: CrmStatisticsProductApi.ProductSalesRespVO[][] = [];
|
||||
let index = 0;
|
||||
while (index < rows.length) {
|
||||
const productKey = getProductKey(rows[index]!);
|
||||
const productRows: CrmStatisticsProductApi.ProductSalesRespVO[] = [];
|
||||
while (index < rows.length && getProductKey(rows[index]!) === productKey) {
|
||||
productRows.push(rows[index]!);
|
||||
index++;
|
||||
}
|
||||
result.push(productRows);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 构建列表展示数据 */
|
||||
function buildList(
|
||||
data: CrmStatisticsProductApi.ProductSalesRespVO[],
|
||||
): ProductSalesRow[] {
|
||||
const result: ProductSalesRow[] = [];
|
||||
let index = 0;
|
||||
let rowIndex = 1;
|
||||
while (index < data.length) {
|
||||
const categoryStartIndex = result.length;
|
||||
const categoryKey = getCategoryKey(data[index]!);
|
||||
const categoryRows: CrmStatisticsProductApi.ProductSalesRespVO[] = [];
|
||||
|
||||
while (index < data.length && getCategoryKey(data[index]!) === categoryKey) {
|
||||
categoryRows.push(data[index]!);
|
||||
index++;
|
||||
}
|
||||
|
||||
const productGroups = buildProductGroups(categoryRows);
|
||||
const productSummaryRows: ProductSalesRow[] = [];
|
||||
productGroups.forEach((productRows) => {
|
||||
const productStartIndex = result.length;
|
||||
productRows.forEach((row) => {
|
||||
const detailIndex = rowIndex++;
|
||||
result.push({
|
||||
...row,
|
||||
rowType: ProductSalesRowTypeEnum.DETAIL,
|
||||
index: detailIndex,
|
||||
rowKey: `detail-${detailIndex}`,
|
||||
});
|
||||
});
|
||||
result[productStartIndex]!.productRowspan = productRows.length;
|
||||
if (productGroups.length > 1 && productRows.length > 1) {
|
||||
productSummaryRows.push(buildProductSummaryRow(productRows));
|
||||
}
|
||||
});
|
||||
|
||||
if (result.length > categoryStartIndex) {
|
||||
result[categoryStartIndex]!.categoryRowspan = categoryRows.length;
|
||||
}
|
||||
result.push(...productSummaryRows);
|
||||
result.push(buildCategorySummaryRow(categoryRows));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 合并产品分类、产品名称单元格 */
|
||||
function spanMethod({ columnIndex, row }: any) {
|
||||
if (
|
||||
row.rowType === ProductSalesRowTypeEnum.PRODUCT_SUMMARY ||
|
||||
row.rowType === ProductSalesRowTypeEnum.CATEGORY_SUMMARY
|
||||
) {
|
||||
if (columnIndex === SUMMARY_LABEL_COLUMN_INDEX) {
|
||||
return { rowspan: 1, colspan: SUMMARY_VALUE_COLUMN_INDEX };
|
||||
}
|
||||
if (
|
||||
columnIndex > SUMMARY_LABEL_COLUMN_INDEX &&
|
||||
columnIndex < SUMMARY_VALUE_COLUMN_INDEX
|
||||
) {
|
||||
return { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
}
|
||||
if (columnIndex === CATEGORY_COLUMN_INDEX) {
|
||||
if (row.rowType !== ProductSalesRowTypeEnum.DETAIL) {
|
||||
return { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
return row.categoryRowspan
|
||||
? { rowspan: row.categoryRowspan, colspan: 1 }
|
||||
: { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
if (columnIndex === PRODUCT_COLUMN_INDEX) {
|
||||
if (row.rowType !== ProductSalesRowTypeEnum.DETAIL) {
|
||||
return undefined;
|
||||
}
|
||||
return row.productRowspan
|
||||
? { rowspan: row.productRowspan, colspan: 1 }
|
||||
: { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获得产品分类销售分析图表 */
|
||||
function getProductCategoryChartOptions(
|
||||
data: CrmStatisticsProductApi.ProductCategoryRespVO[],
|
||||
) {
|
||||
return {
|
||||
title: {
|
||||
text: '产品分类销量占比',
|
||||
left: 'center',
|
||||
bottom: 10,
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
left: 10,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
data: data.map((item) => item.categoryName),
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}<br/>销售数量:{c}',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '销售数量',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['55%', '48%'],
|
||||
data: data.map((item) => ({
|
||||
name: item.categoryName,
|
||||
value: item.productCount,
|
||||
})),
|
||||
},
|
||||
],
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: { show: true, name: '产品分类销量占比' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 加载产品销售情况统计 */
|
||||
async function loadProductSalesList() {
|
||||
const params = await formApi.getValues();
|
||||
const data = await getProductSalesList(params);
|
||||
await productSalesGridApi.grid.reloadData(buildList(data));
|
||||
}
|
||||
|
||||
/** 加载产品分类销售分析 */
|
||||
async function loadProductCategorySummary() {
|
||||
const params = await formApi.getValues();
|
||||
const data = await getProductCategorySummary(params);
|
||||
await renderEcharts(getProductCategoryChartOptions(data), true);
|
||||
await productCategoryGridApi.grid.reloadData(data);
|
||||
}
|
||||
|
||||
/** 查询按钮操作 */
|
||||
async function loadData() {
|
||||
if (activeTabName.value === 'productSalesList') {
|
||||
await loadProductSalesList();
|
||||
return;
|
||||
}
|
||||
await loadProductCategorySummary();
|
||||
}
|
||||
|
||||
/** tab 切换 */
|
||||
async function handleTabChange(key: any) {
|
||||
activeTabName.value = key;
|
||||
await loadData();
|
||||
}
|
||||
|
||||
/** 打开合同详情 */
|
||||
function openContract(id?: number) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
push({ name: 'CrmContractDetail', params: { id } });
|
||||
}
|
||||
|
||||
/** 打开客户详情 */
|
||||
function openCustomer(id?: number) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
push({ name: 'CrmCustomerDetail', params: { id } });
|
||||
}
|
||||
|
||||
/** 打开产品详情 */
|
||||
function openProduct(id?: number) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
push({ name: 'CrmProductDetail', params: { id } });
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<ContentWrap>
|
||||
<QueryForm />
|
||||
<Tabs
|
||||
v-model:active-key="activeTabName"
|
||||
class="w-full"
|
||||
@change="handleTabChange"
|
||||
>
|
||||
<Tabs.TabPane key="productSalesList" tab="产品销售情况统计" />
|
||||
<Tabs.TabPane key="productCategorySummary" tab="产品分类销售分析" />
|
||||
</Tabs>
|
||||
|
||||
<ProductSalesGrid v-show="activeTabName === 'productSalesList'">
|
||||
<template #index="{ row }">
|
||||
<span v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL">
|
||||
{{ row.index }}
|
||||
</span>
|
||||
<span v-else>{{ row.summaryLabel }}</span>
|
||||
</template>
|
||||
<template #productName="{ row }">
|
||||
<Button
|
||||
v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL"
|
||||
type="link"
|
||||
@click="openProduct(row.productId)"
|
||||
>
|
||||
{{ row.productName }}
|
||||
</Button>
|
||||
<span v-else>{{ row.summaryLabel }}</span>
|
||||
</template>
|
||||
<template #contractNo="{ row }">
|
||||
<Button
|
||||
v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL"
|
||||
type="link"
|
||||
@click="openContract(row.contractId)"
|
||||
>
|
||||
{{ row.contractNo }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #customerName="{ row }">
|
||||
<Button
|
||||
v-if="
|
||||
row.rowType === ProductSalesRowTypeEnum.DETAIL && row.customerId
|
||||
"
|
||||
type="link"
|
||||
@click="openCustomer(row.customerId)"
|
||||
>
|
||||
{{ row.customerName }}
|
||||
</Button>
|
||||
<span v-else-if="row.rowType === ProductSalesRowTypeEnum.DETAIL">
|
||||
{{ row.customerName }}
|
||||
</span>
|
||||
</template>
|
||||
</ProductSalesGrid>
|
||||
|
||||
<div v-show="activeTabName === 'productCategorySummary'">
|
||||
<EchartsUI class="mb-5 h-[500px] w-full" ref="chartRef" />
|
||||
<ProductCategoryGrid />
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.product-summary-row > td) {
|
||||
background-color: #fff9f2 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.category-summary-row > td) {
|
||||
background-color: #fff3e8 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.is-link-cell) {
|
||||
color: var(--ant-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user