feat: 迁移 CRM 数据统计与业绩目标设置

- 迁移 CRM 销售漏斗分析,补充漏斗图、阶段统计和商机转化率分析
- 迁移 CRM 员工业绩分析,补充合同汇总统计
- 迁移 CRM 产品分析,新增产品销售情况和产品分类销售分析
- 新增 CRM 业绩达成统计,支持销售目标和回款目标达成情况展示
- 新增 CRM 业绩目标设置,支持部门和员工维度的目标配置
- 补充 web-antd、web-antdv-next、web-ele 对应 API 和页面实现
- 调整统计图表配置、筛选项和表格展示,统一多端 CRM 统计交互
This commit is contained in:
YunaiV
2026-07-06 08:50:17 +08:00
parent d75e4ac32d
commit c96cf0ead9
40 changed files with 3698 additions and 153 deletions

View File

@@ -17,6 +17,16 @@ export namespace CrmStatisticsFunnelApi {
totalPrice: number | string; // 商机金额
}
/** 商机阶段统计响应 */
export interface BusinessSummaryByStatusRespVO {
statusId: number; // 商机阶段编号
statusName: string; // 商机阶段名称
statusPercent: number; // 赢单率
sort: number; // 排序
businessCount: number; // 商机数
totalPrice: number | string; // 商机金额
}
/** 商机转化率分析(按日期)响应 */
export interface BusinessInversionRateSummaryByDateRespVO {
time: string; // 时间
@@ -34,7 +44,7 @@ export function getDatas(activeTabName: any, params: any) {
return getBusinessPageByDate(params);
}
case 'funnel': {
return getBusinessSummaryByEndStatus(params);
return getBusinessSummaryByStatus(params);
}
default: {
return [];
@@ -51,7 +61,7 @@ export function getChartDatas(activeTabName: any, params: any) {
return getBusinessSummaryByDate(params);
}
case 'funnel': {
return getFunnelSummary(params);
return getBusinessSummaryByStatus(params);
}
default: {
return [];
@@ -75,6 +85,13 @@ export function getBusinessSummaryByEndStatus(params: any) {
);
}
/** 获取商机阶段统计 */
export function getBusinessSummaryByStatus(params: any) {
return requestClient.get<
CrmStatisticsFunnelApi.BusinessSummaryByStatusRespVO[]
>('/crm/statistics-funnel/get-business-summary-by-status', { params });
}
/** 获取新增商机分析(按日期) */
export function getBusinessSummaryByDate(params: any) {
return requestClient.get<

View File

@@ -5,7 +5,7 @@ export namespace CrmStatisticsPerformanceApi {
export interface PerformanceReqVO {
times: string[];
deptId: number;
userId: number;
userId?: number;
}
/** 员工业绩统计响应 */
@@ -15,6 +15,15 @@ export namespace CrmStatisticsPerformanceApi {
lastMonthCount: number;
lastYearCount: number;
}
/** 员工业绩合同汇总响应 */
export interface PerformanceSummaryRespVO {
time: string;
contractCount: number;
contractPrice: number;
receivablePrice: number;
unreceivedPrice: number;
}
}
/** 员工获得合同金额统计 */
@@ -46,3 +55,12 @@ export function getContractCountPerformance(
{ params },
);
}
/** 获得合同汇总表 */
export function getContractSummary(
params: CrmStatisticsPerformanceApi.PerformanceReqVO,
) {
return requestClient.get<
CrmStatisticsPerformanceApi.PerformanceSummaryRespVO[]
>('/crm/statistics-performance/get-contract-summary', { params });
}

View File

@@ -1,4 +1,4 @@
import { erpCalculatePercentage } from '@vben/utils';
import { erpCalculatePercentage, erpPriceInputFormatter } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
@@ -190,30 +190,20 @@ export function getChartOptions(
};
}
case 'funnel': {
// tips写死 value 值是为了保持漏斗顺序不变
const list: { name: string; value: number }[] = [];
if (active) {
list.push(
{ value: 60, name: `客户-${res.customerCount || 0}` },
{ value: 40, name: `商机-${res.businessCount || 0}` },
{ value: 20, name: `赢单-${res.businessWinCount || 0}` },
);
} else {
list.push(
{
value: res.customerCount || 0,
name: `客户-${res.customerCount || 0}`,
},
{
value: res.businessCount || 0,
name: `商机-${res.businessCount || 0}`,
},
{
value: res.businessWinCount || 0,
name: `赢单-${res.businessWinCount || 0}`,
},
);
}
const list = res.map((item: any) => ({
value: active
? Number(item.businessCount || 0)
: Number(item.totalPrice || 0),
name: `${item.statusName}-${item.businessCount || 0}`,
statusName: item.statusName,
statusPercent: item.statusPercent,
businessCount: item.businessCount,
totalPrice: item.totalPrice,
}));
const maxValue = Math.max(
...list.map((item: any) => Number(item.value || 0)),
1,
);
return {
title: {
text: '销售漏斗',
@@ -221,7 +211,15 @@ export function getChartOptions(
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{a} <br/>{b}',
formatter: (params: any) => {
const data = params.data || {};
return [
data.statusName || params.name,
`商机数:${data.businessCount || 0}`,
`商机金额:${erpPriceInputFormatter(data.totalPrice || 0)}`,
`赢单率:${data.statusPercent || 0}%`,
].join('<br/>');
},
}),
toolbox: {
feature: {
@@ -231,7 +229,7 @@ export function getChartOptions(
},
},
legend: getLegend({
data: ['客户', '商机', '赢单'],
data: list.map((item: any) => item.name),
}),
series: [
{
@@ -242,10 +240,10 @@ export function getChartOptions(
bottom: 60,
width: '80%',
min: 0,
max: 100,
max: maxValue,
minSize: '0%',
maxSize: '100%',
sort: 'descending',
sort: 'none',
gap: 2,
label: {
show: true,

View File

@@ -6,6 +6,7 @@ import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getBusinessStatusTypeSimpleList } from '#/api/crm/business/status';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -53,6 +54,18 @@ export function useGridFormSchema(): VbenFormSchema[] {
},
defaultValue: 2,
},
{
fieldName: 'statusTypeId',
label: '商机组',
component: 'ApiSelect',
componentProps: {
api: getBusinessStatusTypeSimpleList,
allowClear: true,
labelField: 'name',
valueField: 'id',
placeholder: '请选择商机组',
},
},
{
fieldName: 'deptId',
label: '归属部门',
@@ -243,13 +256,15 @@ export function useGridColumns(
title: '序号',
},
{
field: 'endStatus',
field: 'statusName',
title: '阶段',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE },
},
minWidth: 160,
},
{
field: 'statusPercent',
title: '赢单率',
minWidth: 120,
formatter: ({ row }) => `${row.statusPercent || 0}%`,
},
{
field: 'businessCount',

View File

@@ -5,9 +5,8 @@ import type {
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmStatisticsFunnelApi } from '#/api/crm/statistics/funnel';
import { reactive, ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
@@ -72,7 +71,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsFunnelApi.BusinessSummaryByDateRespVO>,
} as VxeTableGridOptions<any>,
});
/** tab 切换 */
@@ -89,7 +88,10 @@ async function handleTabChange(key: any) {
const queryParams = await formApi.getValues();
const res = await getChartDatas(activeTabName.value, queryParams);
await renderEcharts(getChartOptions(activeTabName.value, active.value, res));
const data: any = await getDatas(activeTabName.value, queryParams);
const data: any =
activeTabName.value === 'funnel'
? res
: await getDatas(activeTabName.value, queryParams);
await gridApi.grid.reloadData(
activeTabName.value === 'funnel' ? data : data.list,
);
@@ -99,10 +101,14 @@ async function handleTabChange(key: any) {
async function handleActive(value: boolean) {
active.value = value;
const queryParams = await formApi.getValues();
renderEcharts(
getChartOptions(activeTabName.value, active.value, queryParams),
);
const res = await getChartDatas(activeTabName.value, queryParams);
renderEcharts(getChartOptions(activeTabName.value, active.value, res));
}
/** 初始化加载 */
onMounted(() => {
handleTabChange(activeTabName.value);
});
</script>
<template>
@@ -127,14 +133,14 @@ async function handleActive(value: boolean) {
v-if="activeTabName === 'funnel'"
@click="handleActive(true)"
>
客户视角
阶段视角
</Button>
<Button
:type="active ? 'default' : 'primary'"
v-if="activeTabName === 'funnel'"
@click="handleActive(false)"
>
动态视角
金额视角
</Button>
</ButtonGroup>
<EchartsUI class="mb-20 h-2/5 w-full" ref="chartRef" />

View File

@@ -388,6 +388,50 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
case 'ContractSummary': {
return {
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '合同金额(元)',
type: 'bar',
data: res.map((s: any) => s.contractPrice),
},
{
name: '回款金额(元)',
type: 'bar',
data: res.map((s: any) => s.receivablePrice),
},
{
name: '未回款金额(元)',
type: 'line',
data: res.map((s: any) => s.unreceivedPrice),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false,
},
brush: {
type: ['lineX', 'clear'],
},
saveAsImage: { show: true, name: '合同汇总表' },
},
},
tooltip: getTooltip(),
yAxis: {
type: 'value',
name: '金额(元)',
},
xAxis: {
type: 'category',
name: '月份',
data: res.map((s: any) => s.time),
},
};
}
default: {
return {};
}

View File

@@ -21,6 +21,10 @@ export const customerSummaryTabs = [
tab: '员工回款金额统计',
key: 'ReceivablePricePerformance',
},
{
tab: '合同汇总表',
key: 'ContractSummary',
},
];
/** 列表的搜索表单 */

View File

@@ -2,13 +2,12 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { beginOfDay, endOfDay, formatDateTime } from '@vben/utils';
import { beginOfDay, endOfDay, formatDate } from '@vben/utils';
import { Tabs } from 'ant-design-vue';
@@ -17,6 +16,7 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getContractCountPerformance,
getContractPricePerformance,
getContractSummary,
getReceivablePricePerformance,
} from '#/api/crm/statistics/performance';
import { $t } from '#/locales';
@@ -62,7 +62,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUserRespVO>,
} as VxeTableGridOptions<any>,
});
/** tab 切换 */
@@ -72,8 +72,14 @@ async function handleTabChange(key: any) {
// 将年份转换为年初和年末的日期时间
const selectYear = Number.parseInt(queryParams.time);
queryParams.times = [];
queryParams.times[0] = formatDateTime(beginOfDay(new Date(selectYear, 0, 1)));
queryParams.times[1] = formatDateTime(endOfDay(new Date(selectYear, 11, 31)));
queryParams.times[0] = formatDate(
beginOfDay(new Date(selectYear, 0, 1)),
'YYYY-MM-DD HH:mm:ss',
);
queryParams.times[1] = formatDate(
endOfDay(new Date(selectYear, 11, 31)),
'YYYY-MM-DD HH:mm:ss',
);
let data: any[] = [];
const columnsData: any[] = [];
let tableData: any[] = [];
@@ -111,6 +117,35 @@ async function handleTabChange(key: any) {
data = await getReceivablePricePerformance(queryParams);
break;
}
case 'ContractSummary': {
data = await getContractSummary(queryParams);
columnsData.push(
{ title: '月份', field: 'time', minWidth: 120 },
{ title: '合同数量', field: 'contractCount', minWidth: 120 },
{
title: '合同金额(元)',
field: 'contractPrice',
formatter: 'formatAmount2',
minWidth: 160,
},
{
title: '回款金额(元)',
field: 'receivablePrice',
formatter: 'formatAmount2',
minWidth: 160,
},
{
title: '未回款金额(元)',
field: 'unreceivedPrice',
formatter: 'formatAmount2',
minWidth: 160,
},
);
await renderEcharts(getChartOptions(key, data), true);
await gridApi.grid.reloadColumn(columnsData);
await gridApi.grid.reloadData(data);
return;
}
default: {
break;
}

View 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}`);
}

View File

@@ -17,6 +17,16 @@ export namespace CrmStatisticsFunnelApi {
totalPrice: number | string; // 商机金额
}
/** 商机阶段统计响应 */
export interface BusinessSummaryByStatusRespVO {
statusId: number; // 商机阶段编号
statusName: string; // 商机阶段名称
statusPercent: number; // 赢单率
sort: number; // 排序
businessCount: number; // 商机数
totalPrice: number | string; // 商机金额
}
/** 商机转化率分析(按日期)响应 */
export interface BusinessInversionRateSummaryByDateRespVO {
time: string; // 时间
@@ -34,7 +44,7 @@ export function getDatas(activeTabName: any, params: any) {
return getBusinessPageByDate(params);
}
case 'funnel': {
return getBusinessSummaryByEndStatus(params);
return getBusinessSummaryByStatus(params);
}
default: {
return [];
@@ -51,7 +61,7 @@ export function getChartDatas(activeTabName: any, params: any) {
return getBusinessSummaryByDate(params);
}
case 'funnel': {
return getFunnelSummary(params);
return getBusinessSummaryByStatus(params);
}
default: {
return [];
@@ -75,6 +85,13 @@ export function getBusinessSummaryByEndStatus(params: any) {
);
}
/** 获取商机阶段统计 */
export function getBusinessSummaryByStatus(params: any) {
return requestClient.get<
CrmStatisticsFunnelApi.BusinessSummaryByStatusRespVO[]
>('/crm/statistics-funnel/get-business-summary-by-status', { params });
}
/** 获取新增商机分析(按日期) */
export function getBusinessSummaryByDate(params: any) {
return requestClient.get<

View File

@@ -5,7 +5,7 @@ export namespace CrmStatisticsPerformanceApi {
export interface PerformanceReqVO {
times: string[];
deptId: number;
userId: number;
userId?: number;
}
/** 员工业绩统计响应 */
@@ -15,6 +15,15 @@ export namespace CrmStatisticsPerformanceApi {
lastMonthCount: number;
lastYearCount: number;
}
/** 员工业绩合同汇总响应 */
export interface PerformanceSummaryRespVO {
time: string;
contractCount: number;
contractPrice: number;
receivablePrice: number;
unreceivedPrice: number;
}
}
/** 员工获得合同金额统计 */
@@ -46,3 +55,12 @@ export function getContractCountPerformance(
{ params },
);
}
/** 获得合同汇总表 */
export function getContractSummary(
params: CrmStatisticsPerformanceApi.PerformanceReqVO,
) {
return requestClient.get<
CrmStatisticsPerformanceApi.PerformanceSummaryRespVO[]
>('/crm/statistics-performance/get-contract-summary', { params });
}

View 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,
});
}

View 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 },
);
}

View 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 || '';
}

View 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 'antdv-next';
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>

View 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 'antdv-next';
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>

View File

@@ -1,4 +1,4 @@
import { erpCalculatePercentage } from '@vben/utils';
import { erpCalculatePercentage, erpPriceInputFormatter } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
@@ -190,30 +190,20 @@ export function getChartOptions(
};
}
case 'funnel': {
// tips写死 value 值是为了保持漏斗顺序不变
const list: { name: string; value: number }[] = [];
if (active) {
list.push(
{ value: 60, name: `客户-${res.customerCount || 0}` },
{ value: 40, name: `商机-${res.businessCount || 0}` },
{ value: 20, name: `赢单-${res.businessWinCount || 0}` },
);
} else {
list.push(
{
value: res.customerCount || 0,
name: `客户-${res.customerCount || 0}`,
},
{
value: res.businessCount || 0,
name: `商机-${res.businessCount || 0}`,
},
{
value: res.businessWinCount || 0,
name: `赢单-${res.businessWinCount || 0}`,
},
);
}
const list = res.map((item: any) => ({
value: active
? Number(item.businessCount || 0)
: Number(item.totalPrice || 0),
name: `${item.statusName}-${item.businessCount || 0}`,
statusName: item.statusName,
statusPercent: item.statusPercent,
businessCount: item.businessCount,
totalPrice: item.totalPrice,
}));
const maxValue = Math.max(
...list.map((item: any) => Number(item.value || 0)),
1,
);
return {
title: {
text: '销售漏斗',
@@ -221,7 +211,15 @@ export function getChartOptions(
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{a} <br/>{b}',
formatter: (params: any) => {
const data = params.data || {};
return [
data.statusName || params.name,
`商机数:${data.businessCount || 0}`,
`商机金额:${erpPriceInputFormatter(data.totalPrice || 0)}`,
`赢单率:${data.statusPercent || 0}%`,
].join('<br/>');
},
}),
toolbox: {
feature: {
@@ -231,7 +229,7 @@ export function getChartOptions(
},
},
legend: getLegend({
data: ['客户', '商机', '赢单'],
data: list.map((item: any) => item.name),
}),
series: [
{
@@ -242,10 +240,10 @@ export function getChartOptions(
bottom: 60,
width: '80%',
min: 0,
max: 100,
max: maxValue,
minSize: '0%',
maxSize: '100%',
sort: 'descending',
sort: 'none',
gap: 2,
label: {
show: true,

View File

@@ -6,6 +6,7 @@ import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getBusinessStatusTypeSimpleList } from '#/api/crm/business/status';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -53,6 +54,18 @@ export function useGridFormSchema(): VbenFormSchema[] {
},
defaultValue: 2,
},
{
fieldName: 'statusTypeId',
label: '商机组',
component: 'ApiSelect',
componentProps: {
api: getBusinessStatusTypeSimpleList,
allowClear: true,
labelField: 'name',
valueField: 'id',
placeholder: '请选择商机组',
},
},
{
fieldName: 'deptId',
label: '归属部门',
@@ -243,13 +256,15 @@ export function useGridColumns(
title: '序号',
},
{
field: 'endStatus',
field: 'statusName',
title: '阶段',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE },
},
minWidth: 160,
},
{
field: 'statusPercent',
title: '赢单率',
minWidth: 120,
formatter: ({ row }) => `${row.statusPercent || 0}%`,
},
{
field: 'businessCount',

View File

@@ -5,9 +5,8 @@ import type {
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmStatisticsFunnelApi } from '#/api/crm/statistics/funnel';
import { reactive, ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
@@ -72,7 +71,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsFunnelApi.BusinessSummaryByDateRespVO>,
} as VxeTableGridOptions<any>,
});
/** tab 切换 */
@@ -89,7 +88,10 @@ async function handleTabChange(key: any) {
const queryParams = await formApi.getValues();
const res = await getChartDatas(activeTabName.value, queryParams);
await renderEcharts(getChartOptions(activeTabName.value, active.value, res));
const data: any = await getDatas(activeTabName.value, queryParams);
const data: any =
activeTabName.value === 'funnel'
? res
: await getDatas(activeTabName.value, queryParams);
await gridApi.grid.reloadData(
activeTabName.value === 'funnel' ? data : data.list,
);
@@ -99,10 +101,14 @@ async function handleTabChange(key: any) {
async function handleActive(value: boolean) {
active.value = value;
const queryParams = await formApi.getValues();
renderEcharts(
getChartOptions(activeTabName.value, active.value, queryParams),
);
const res = await getChartDatas(activeTabName.value, queryParams);
renderEcharts(getChartOptions(activeTabName.value, active.value, res));
}
/** 初始化加载 */
onMounted(() => {
handleTabChange(activeTabName.value);
});
</script>
<template>
@@ -127,14 +133,14 @@ async function handleActive(value: boolean) {
v-if="activeTabName === 'funnel'"
@click="handleActive(true)"
>
客户视角
阶段视角
</Button>
<Button
:type="active ? 'default' : 'primary'"
v-if="activeTabName === 'funnel'"
@click="handleActive(false)"
>
动态视角
金额视角
</Button>
</Space>
<EchartsUI class="mb-20 h-2/5 w-full" ref="chartRef" />

View File

@@ -388,6 +388,50 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
case 'ContractSummary': {
return {
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '合同金额(元)',
type: 'bar',
data: res.map((s: any) => s.contractPrice),
},
{
name: '回款金额(元)',
type: 'bar',
data: res.map((s: any) => s.receivablePrice),
},
{
name: '未回款金额(元)',
type: 'line',
data: res.map((s: any) => s.unreceivedPrice),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false,
},
brush: {
type: ['lineX', 'clear'],
},
saveAsImage: { show: true, name: '合同汇总表' },
},
},
tooltip: getTooltip(),
yAxis: {
type: 'value',
name: '金额(元)',
},
xAxis: {
type: 'category',
name: '月份',
data: res.map((s: any) => s.time),
},
};
}
default: {
return {};
}

View File

@@ -21,6 +21,10 @@ export const customerSummaryTabs = [
tab: '员工回款金额统计',
key: 'ReceivablePricePerformance',
},
{
tab: '合同汇总表',
key: 'ContractSummary',
},
];
/** 列表的搜索表单 */

View File

@@ -2,13 +2,12 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { beginOfDay, endOfDay, formatDateTime } from '@vben/utils';
import { beginOfDay, endOfDay, formatDate } from '@vben/utils';
import { TabPane, Tabs } from 'antdv-next';
@@ -17,6 +16,7 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getContractCountPerformance,
getContractPricePerformance,
getContractSummary,
getReceivablePricePerformance,
} from '#/api/crm/statistics/performance';
import { $t } from '#/locales';
@@ -62,7 +62,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUserRespVO>,
} as VxeTableGridOptions<any>,
});
/** tab 切换 */
@@ -72,8 +72,14 @@ async function handleTabChange(key: any) {
// 将年份转换为年初和年末的日期时间
const selectYear = Number.parseInt(queryParams.time);
queryParams.times = [];
queryParams.times[0] = formatDateTime(beginOfDay(new Date(selectYear, 0, 1)));
queryParams.times[1] = formatDateTime(endOfDay(new Date(selectYear, 11, 31)));
queryParams.times[0] = formatDate(
beginOfDay(new Date(selectYear, 0, 1)),
'YYYY-MM-DD HH:mm:ss',
);
queryParams.times[1] = formatDate(
endOfDay(new Date(selectYear, 11, 31)),
'YYYY-MM-DD HH:mm:ss',
);
let data: any[] = [];
const columnsData: any[] = [];
let tableData: any[] = [];
@@ -111,6 +117,35 @@ async function handleTabChange(key: any) {
data = await getReceivablePricePerformance(queryParams);
break;
}
case 'ContractSummary': {
data = await getContractSummary(queryParams);
columnsData.push(
{ title: '月份', field: 'time', minWidth: 120 },
{ title: '合同数量', field: 'contractCount', minWidth: 120 },
{
title: '合同金额(元)',
field: 'contractPrice',
formatter: 'formatAmount2',
minWidth: 160,
},
{
title: '回款金额(元)',
field: 'receivablePrice',
formatter: 'formatAmount2',
minWidth: 160,
},
{
title: '未回款金额(元)',
field: 'unreceivedPrice',
formatter: 'formatAmount2',
minWidth: 160,
},
);
await renderEcharts(getChartOptions(key, data), true);
await gridApi.grid.reloadColumn(columnsData);
await gridApi.grid.reloadData(data);
return;
}
default: {
break;
}

View File

@@ -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>

View 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, TabPane, Tabs } from 'antdv-next';
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"
>
<TabPane key="productSalesList" tab="产品销售情况统计" />
<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>

View 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}`);
}

View File

@@ -17,6 +17,16 @@ export namespace CrmStatisticsFunnelApi {
totalPrice: number | string; // 商机金额
}
/** 商机阶段统计响应 */
export interface BusinessSummaryByStatusRespVO {
statusId: number; // 商机阶段编号
statusName: string; // 商机阶段名称
statusPercent: number; // 赢单率
sort: number; // 排序
businessCount: number; // 商机数
totalPrice: number | string; // 商机金额
}
/** 商机转化率分析(按日期)响应 */
export interface BusinessInversionRateSummaryByDateRespVO {
time: string; // 时间
@@ -34,7 +44,7 @@ export function getDatas(activeTabName: any, params: any) {
return getBusinessPageByDate(params);
}
case 'funnel': {
return getBusinessSummaryByEndStatus(params);
return getBusinessSummaryByStatus(params);
}
default: {
return [];
@@ -51,7 +61,7 @@ export function getChartDatas(activeTabName: any, params: any) {
return getBusinessSummaryByDate(params);
}
case 'funnel': {
return getFunnelSummary(params);
return getBusinessSummaryByStatus(params);
}
default: {
return [];
@@ -75,6 +85,13 @@ export function getBusinessSummaryByEndStatus(params: any) {
);
}
/** 获取商机阶段统计 */
export function getBusinessSummaryByStatus(params: any) {
return requestClient.get<
CrmStatisticsFunnelApi.BusinessSummaryByStatusRespVO[]
>('/crm/statistics-funnel/get-business-summary-by-status', { params });
}
/** 获取新增商机分析(按日期) */
export function getBusinessSummaryByDate(params: any) {
return requestClient.get<

View File

@@ -5,7 +5,7 @@ export namespace CrmStatisticsPerformanceApi {
export interface PerformanceReqVO {
times: string[];
deptId: number;
userId: number;
userId?: number;
}
/** 员工业绩统计响应 */
@@ -15,6 +15,15 @@ export namespace CrmStatisticsPerformanceApi {
lastMonthCount: number;
lastYearCount: number;
}
/** 员工业绩合同汇总响应 */
export interface PerformanceSummaryRespVO {
time: string;
contractCount: number;
contractPrice: number;
receivablePrice: number;
unreceivedPrice: number;
}
}
/** 员工获得合同金额统计 */
@@ -46,3 +55,12 @@ export function getContractCountPerformance(
{ params },
);
}
/** 获得合同汇总表 */
export function getContractSummary(
params: CrmStatisticsPerformanceApi.PerformanceReqVO,
) {
return requestClient.get<
CrmStatisticsPerformanceApi.PerformanceSummaryRespVO[]
>('/crm/statistics-performance/get-contract-summary', { params });
}

View 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,
});
}

View 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 },
);
}

View File

@@ -0,0 +1,169 @@
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: {
type: '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',
defaultExpandAll: true,
placeholder: '请选择部门',
clearable: true,
},
dependencies: {
triggerFields: ['objectType'],
show: (values) =>
values.objectType === PerformanceConfigObjectTypeEnum.DEPT,
},
},
{
fieldName: 'userObjectId',
label: '员工',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
allowClear: true,
filterable: true,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择员工',
},
dependencies: {
triggerFields: ['objectType'],
show: (values) =>
values.objectType === PerformanceConfigObjectTypeEnum.USER,
},
},
];
}
/** 列表的字段 */
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 || '';
}

View 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 { ElLoading, ElMessage } from 'element-plus';
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 loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', ['业绩目标']),
});
try {
await deletePerformanceConfig(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', ['业绩目标']));
handleRefresh();
} finally {
loadingInstance.close();
}
}
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: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:performance-config:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: 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>

View File

@@ -0,0 +1,247 @@
<script lang="ts" setup>
import type { FormInstance, FormRules } from 'element-plus';
import type { CrmPerformanceConfigApi } from '#/api/crm/performance/config';
import type { SystemUserApi } from '#/api/system/user';
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { erpPriceInputFormatter, handleTree } from '@vben/utils';
import { ElMessage } from 'element-plus';
import {
createPerformanceConfig,
getPerformanceConfig,
PerformanceConfigObjectTypeEnum,
updatePerformanceConfig,
} from '#/api/crm/performance/config';
import { BizTypeEnum } from '#/api/crm/permission';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { $t } from '#/locales';
import { bizTypeOptions, monthFields, objectTypeOptions } from '../data';
const emit = defineEmits(['success']);
const formRef = ref<FormInstance>();
const formData = ref<any>({});
const deptList = ref<any[]>([]);
const userList = ref<SystemUserApi.User[]>([]);
const treeProps = { label: 'name', value: 'id', children: 'children' };
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['业绩目标'])
: $t('ui.actionTitle.create', ['业绩目标']);
});
const formRules = reactive<FormRules>({
year: [{ required: true, message: '年份不能为空', trigger: 'change' }],
bizType: [{ required: true, message: '目标类型不能为空', trigger: 'change' }],
objectType: [
{ required: true, message: '对象类型不能为空', trigger: 'change' },
],
objectId: [{ required: true, message: '目标对象不能为空', trigger: 'change' }],
});
/** 年度目标金额 */
const yearTargetPrice = computed(() =>
monthFields.reduce(
(sum, item) => sum + Number(formData.value[item.prop] || 0),
0,
),
);
/** 年度目标金额展示文本 */
const yearTargetPriceText = computed(() =>
erpPriceInputFormatter(yearTargetPrice.value),
);
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const valid = await formRef.value?.validate();
if (!valid) {
return;
}
modalApi.lock();
try {
const data = {
...formData.value,
year: Number(formData.value.year),
} as CrmPerformanceConfigApi.PerformanceConfig;
await (data.id
? updatePerformanceConfig(data)
: createPerformanceConfig(data));
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
resetForm();
await loadOptions();
const data =
modalApi.getData<CrmPerformanceConfigApi.PerformanceConfig>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
const detail = await getPerformanceConfig(data.id);
formData.value = {
...detail,
year: String(detail.year),
};
} finally {
modalApi.unlock();
}
},
});
/** 对象类型变化时重置目标对象 */
function handleObjectTypeChange() {
formData.value.objectId = undefined;
}
/** 加载部门和员工选项 */
async function loadOptions() {
if (!deptList.value.length) {
deptList.value = handleTree(await getSimpleDeptList());
}
if (!userList.value.length) {
userList.value = await getSimpleUserList();
}
}
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
objectId: undefined,
objectType: PerformanceConfigObjectTypeEnum.DEPT,
year: new Date().getFullYear().toString(),
bizType: BizTypeEnum.CRM_CONTRACT,
januaryTargetPrice: 0,
februaryTargetPrice: 0,
marchTargetPrice: 0,
aprilTargetPrice: 0,
mayTargetPrice: 0,
juneTargetPrice: 0,
julyTargetPrice: 0,
augustTargetPrice: 0,
septemberTargetPrice: 0,
octoberTargetPrice: 0,
novemberTargetPrice: 0,
decemberTargetPrice: 0,
};
formRef.value?.resetFields();
}
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<ElForm
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<ElRow :gutter="16">
<ElCol :span="8">
<ElFormItem label="年份" prop="year">
<ElDatePicker
v-model="formData.year"
class="!w-full"
type="year"
value-format="YYYY"
/>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="目标类型" prop="bizType">
<ElSelect
v-model="formData.bizType"
class="!w-full"
placeholder="请选择目标类型"
>
<ElOption
v-for="item in bizTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem label="对象类型" prop="objectType">
<ElSelect
v-model="formData.objectType"
class="!w-full"
placeholder="请选择对象类型"
@change="handleObjectTypeChange"
>
<ElOption
v-for="item in objectTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="目标对象" prop="objectId">
<ElTreeSelect
v-if="formData.objectType === PerformanceConfigObjectTypeEnum.DEPT"
v-model="formData.objectId"
:data="deptList"
:props="treeProps"
check-strictly
class="!w-full"
node-key="id"
placeholder="请选择部门"
/>
<ElSelect
v-else
v-model="formData.objectId"
class="!w-full"
filterable
placeholder="请选择员工"
>
<ElOption
v-for="user in userList"
:key="user.id"
:label="user.nickname"
:value="user.id"
/>
</ElSelect>
</ElFormItem>
<ElDivider />
<ElRow :gutter="16">
<ElCol v-for="item in monthFields" :key="item.prop" :span="6">
<ElFormItem :label="item.label" :prop="item.prop">
<ElInputNumber
v-model="formData[item.prop]"
:min="0"
:precision="2"
:step="1000"
class="!w-full"
controls-position="right"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="年度目标">
<ElInput :model-value="yearTargetPriceText" disabled />
</ElFormItem>
</ElForm>
</Modal>
</template>

View File

@@ -1,4 +1,4 @@
import { erpCalculatePercentage } from '@vben/utils';
import { erpCalculatePercentage, erpPriceInputFormatter } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
@@ -190,30 +190,20 @@ export function getChartOptions(
};
}
case 'funnel': {
// tips写死 value 值是为了保持漏斗顺序不变
const list: { name: string; value: number }[] = [];
if (active) {
list.push(
{ value: 60, name: `客户-${res.customerCount || 0}` },
{ value: 40, name: `商机-${res.businessCount || 0}` },
{ value: 20, name: `赢单-${res.businessWinCount || 0}` },
);
} else {
list.push(
{
value: res.customerCount || 0,
name: `客户-${res.customerCount || 0}`,
},
{
value: res.businessCount || 0,
name: `商机-${res.businessCount || 0}`,
},
{
value: res.businessWinCount || 0,
name: `赢单-${res.businessWinCount || 0}`,
},
);
}
const list = res.map((item: any) => ({
value: active
? Number(item.businessCount || 0)
: Number(item.totalPrice || 0),
name: `${item.statusName}-${item.businessCount || 0}`,
statusName: item.statusName,
statusPercent: item.statusPercent,
businessCount: item.businessCount,
totalPrice: item.totalPrice,
}));
const maxValue = Math.max(
...list.map((item: any) => Number(item.value || 0)),
1,
);
return {
title: {
text: '销售漏斗',
@@ -221,7 +211,15 @@ export function getChartOptions(
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{a} <br/>{b}',
formatter: (params: any) => {
const data = params.data || {};
return [
data.statusName || params.name,
`商机数:${data.businessCount || 0}`,
`商机金额:${erpPriceInputFormatter(data.totalPrice || 0)}`,
`赢单率:${data.statusPercent || 0}%`,
].join('<br/>');
},
}),
toolbox: {
feature: {
@@ -231,7 +229,7 @@ export function getChartOptions(
},
},
legend: getLegend({
data: ['客户', '商机', '赢单'],
data: list.map((item: any) => item.name),
}),
series: [
{
@@ -242,10 +240,10 @@ export function getChartOptions(
bottom: 60,
width: '80%',
min: 0,
max: 100,
max: maxValue,
minSize: '0%',
maxSize: '100%',
sort: 'descending',
sort: 'none',
gap: 2,
label: {
show: true,

View File

@@ -6,6 +6,7 @@ import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { beginOfDay, endOfDay, formatDateTime, handleTree } from '@vben/utils';
import { getBusinessStatusTypeSimpleList } from '#/api/crm/business/status';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -53,6 +54,18 @@ export function useGridFormSchema(): VbenFormSchema[] {
},
defaultValue: 2,
},
{
fieldName: 'statusTypeId',
label: '商机组',
component: 'ApiSelect',
componentProps: {
api: getBusinessStatusTypeSimpleList,
allowClear: true,
labelField: 'name',
valueField: 'id',
placeholder: '请选择商机组',
},
},
{
fieldName: 'deptId',
label: '归属部门',
@@ -243,13 +256,15 @@ export function useGridColumns(
title: '序号',
},
{
field: 'endStatus',
field: 'statusName',
title: '阶段',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE },
},
minWidth: 160,
},
{
field: 'statusPercent',
title: '赢单率',
minWidth: 120,
formatter: ({ row }) => `${row.statusPercent || 0}%`,
},
{
field: 'businessCount',

View File

@@ -5,9 +5,8 @@ import type {
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmStatisticsFunnelApi } from '#/api/crm/statistics/funnel';
import { reactive, ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
@@ -72,7 +71,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsFunnelApi.BusinessSummaryByDateRespVO>,
} as VxeTableGridOptions<any>,
});
/** tab 切换 */
@@ -89,7 +88,10 @@ async function handleTabChange(key: any) {
const queryParams = await formApi.getValues();
const res = await getChartDatas(activeTabName.value, queryParams);
await renderEcharts(getChartOptions(activeTabName.value, active.value, res));
const data: any = await getDatas(activeTabName.value, queryParams);
const data: any =
activeTabName.value === 'funnel'
? res
: await getDatas(activeTabName.value, queryParams);
await gridApi.grid.reloadData(
activeTabName.value === 'funnel' ? data : data.list,
);
@@ -99,10 +101,14 @@ async function handleTabChange(key: any) {
async function handleActive(value: boolean) {
active.value = value;
const queryParams = await formApi.getValues();
renderEcharts(
getChartOptions(activeTabName.value, active.value, queryParams),
);
const res = await getChartDatas(activeTabName.value, queryParams);
renderEcharts(getChartOptions(activeTabName.value, active.value, res));
}
/** 初始化加载 */
onMounted(() => {
handleTabChange(activeTabName.value);
});
</script>
<template>
@@ -127,14 +133,14 @@ async function handleActive(value: boolean) {
v-if="activeTabName === 'funnel'"
@click="handleActive(true)"
>
客户视角
阶段视角
</ElButton>
<ElButton
:type="active ? 'default' : 'primary'"
v-if="activeTabName === 'funnel'"
@click="handleActive(false)"
>
动态视角
金额视角
</ElButton>
</ElButtonGroup>
<EchartsUI class="mb-20 h-2/5 w-full" ref="chartRef" />

View File

@@ -388,6 +388,50 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
case 'ContractSummary': {
return {
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '合同金额(元)',
type: 'bar',
data: res.map((s: any) => s.contractPrice),
},
{
name: '回款金额(元)',
type: 'bar',
data: res.map((s: any) => s.receivablePrice),
},
{
name: '未回款金额(元)',
type: 'line',
data: res.map((s: any) => s.unreceivedPrice),
},
],
toolbox: {
feature: {
dataZoom: {
xAxisIndex: false,
},
brush: {
type: ['lineX', 'clear'],
},
saveAsImage: { show: true, name: '合同汇总表' },
},
},
tooltip: getTooltip(),
yAxis: {
type: 'value',
name: '金额(元)',
},
xAxis: {
type: 'category',
name: '月份',
data: res.map((s: any) => s.time),
},
};
}
default: {
return {};
}

View File

@@ -21,6 +21,10 @@ export const customerSummaryTabs = [
tab: '员工回款金额统计',
key: 'ReceivablePricePerformance',
},
{
tab: '合同汇总表',
key: 'ContractSummary',
},
];
/** 列表的搜索表单 */

View File

@@ -2,7 +2,6 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmStatisticsCustomerApi } from '#/api/crm/statistics/customer';
import { onMounted, ref } from 'vue';
@@ -17,6 +16,7 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getContractCountPerformance,
getContractPricePerformance,
getContractSummary,
getReceivablePricePerformance,
} from '#/api/crm/statistics/performance';
import { $t } from '#/locales';
@@ -62,7 +62,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<CrmStatisticsCustomerApi.CustomerSummaryByUserRespVO>,
} as VxeTableGridOptions<any>,
});
/** tab 切换 */
@@ -117,6 +117,35 @@ async function handleTabChange(key: any) {
data = await getReceivablePricePerformance(queryParams);
break;
}
case 'ContractSummary': {
data = await getContractSummary(queryParams);
columnsData.push(
{ title: '月份', field: 'time', minWidth: 120 },
{ title: '合同数量', field: 'contractCount', minWidth: 120 },
{
title: '合同金额(元)',
field: 'contractPrice',
formatter: 'formatAmount2',
minWidth: 160,
},
{
title: '回款金额(元)',
field: 'receivablePrice',
formatter: 'formatAmount2',
minWidth: 160,
},
{
title: '未回款金额(元)',
field: 'unreceivedPrice',
formatter: 'formatAmount2',
minWidth: 160,
},
);
await renderEcharts(getChartOptions(key, data), true);
await gridApi.grid.reloadColumn(columnsData);
await gridApi.grid.reloadData(data);
return;
}
default: {
break;
}

View File

@@ -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: {
type: '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',
defaultExpandAll: 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>

View File

@@ -0,0 +1,588 @@
<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 { ElButton, ElTabPane, ElTabs } from 'element-plus';
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(),
clearable: 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',
defaultExpandAll: 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',
defaultExpandAll: true,
placeholder: '请选择产品分类',
clearable: true,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getProductSimpleList,
allowClear: true,
filterable: 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 />
<ElTabs
v-model="activeTabName"
class="w-full"
@tab-change="handleTabChange"
>
<ElTabPane label="产品销售情况统计" name="productSalesList" />
<ElTabPane label="产品分类销售分析" name="productCategorySummary" />
</ElTabs>
<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 }">
<ElButton
v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL"
link
type="primary"
@click="openProduct(row.productId)"
>
{{ row.productName }}
</ElButton>
<span v-else>{{ row.summaryLabel }}</span>
</template>
<template #contractNo="{ row }">
<ElButton
v-if="row.rowType === ProductSalesRowTypeEnum.DETAIL"
link
type="primary"
@click="openContract(row.contractId)"
>
{{ row.contractNo }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton
v-if="
row.rowType === ProductSalesRowTypeEnum.DETAIL && row.customerId
"
link
type="primary"
@click="openCustomer(row.customerId)"
>
{{ row.customerName }}
</ElButton>
<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(--el-color-primary);
cursor: pointer;
}
</style>