feat: business detail

pull/128/head
xingyu4j 2025-06-04 20:21:20 +08:00
parent c18d70b0bc
commit 9f24438d2e
9 changed files with 685 additions and 67 deletions

View File

@ -40,6 +40,7 @@ export namespace CrmBusinessApi {
totalProductPrice: number;
totalPrice: number;
discountPercent: number;
status?: number;
remark: string;
creator: string; // 创建人
creatorName?: string; // 创建人名称
@ -47,6 +48,12 @@ export namespace CrmBusinessApi {
updateTime: Date; // 更新时间
products?: BusinessProduct[];
}
export interface BusinessStatus {
id: number;
statusId: number | undefined;
endStatus: number | undefined;
}
}
/** 查询商机列表 */
@ -90,7 +97,7 @@ export function updateBusiness(data: CrmBusinessApi.Business) {
}
/** 修改商机状态 */
export function updateBusinessStatus(data: CrmBusinessApi.Business) {
export function updateBusinessStatus(data: CrmBusinessApi.BusinessStatus) {
return requestClient.put('/crm/business/update-status', data);
}

View File

@ -4,46 +4,50 @@ import { requestClient } from '#/api/request';
export namespace CrmBusinessStatusApi {
/** 商机状态信息 */
export interface BusinessStatus {
id: number;
name: string;
percent: number;
}
/** 商机状态组信息 */
export interface BusinessStatusType {
id: number;
name: string;
deptIds: number[];
statuses?: BusinessStatus[];
percent: number;
sort: number;
}
/** 默认商机状态 */
export const DEFAULT_STATUSES = [
{
endStatus: 1,
key: '结束',
name: '赢单',
percent: 100,
},
{
endStatus: 2,
key: '结束',
name: '输单',
percent: 0,
},
{
endStatus: 3,
key: '结束',
name: '无效',
percent: 0,
},
] as const;
/** 商机状态组信息 */
export interface BusinessStatus {
id: number;
name: string;
deptIds: number[];
deptNames: string[];
creator: string;
createTime: Date;
statuses?: BusinessStatusType[];
}
}
/** 默认商机状态 */
export const DEFAULT_STATUSES = [
{
endStatus: 1,
key: '结束',
name: '赢单',
percent: 100,
},
{
endStatus: 2,
key: '结束',
name: '输单',
percent: 0,
},
{
endStatus: 3,
key: '结束',
name: '无效',
percent: 0,
},
];
/** 查询商机状态组列表 */
export function getBusinessStatusPage(params: PageParam) {
return requestClient.get<PageResult<CrmBusinessStatusApi.BusinessStatusType>>(
return requestClient.get<PageResult<CrmBusinessStatusApi.BusinessStatus>>(
'/crm/business-status/page',
{ params },
);
@ -51,21 +55,21 @@ export function getBusinessStatusPage(params: PageParam) {
/** 新增商机状态组 */
export function createBusinessStatus(
data: CrmBusinessStatusApi.BusinessStatusType,
data: CrmBusinessStatusApi.BusinessStatus,
) {
return requestClient.post('/crm/business-status/create', data);
}
/** 修改商机状态组 */
export function updateBusinessStatus(
data: CrmBusinessStatusApi.BusinessStatusType,
data: CrmBusinessStatusApi.BusinessStatus,
) {
return requestClient.put('/crm/business-status/update', data);
}
/** 查询商机状态类型详情 */
export function getBusinessStatus(id: number) {
return requestClient.get<CrmBusinessStatusApi.BusinessStatusType>(
return requestClient.get<CrmBusinessStatusApi.BusinessStatus>(
`/crm/business-status/get?id=${id}`,
);
}
@ -77,14 +81,14 @@ export function deleteBusinessStatus(id: number) {
/** 获得商机状态组列表 */
export function getBusinessStatusTypeSimpleList() {
return requestClient.get<CrmBusinessStatusApi.BusinessStatusType[]>(
return requestClient.get<CrmBusinessStatusApi.BusinessStatus[]>(
'/crm/business-status/type-simple-list',
);
}
/** 获得商机阶段列表 */
export function getBusinessStatusSimpleList(typeId: number) {
return requestClient.get<CrmBusinessStatusApi.BusinessStatus[]>(
return requestClient.get<CrmBusinessStatusApi.BusinessStatusType[]>(
'/crm/business-status/status-simple-list',
{ params: { typeId } },
);

View File

@ -80,3 +80,107 @@ export function calculateRelativeRate(
((100 * ((value || 0) - reference)) / reference).toFixed(0),
);
}
// ========== ERP 专属方法 ==========
const ERP_COUNT_DIGIT = 3;
const ERP_PRICE_DIGIT = 2;
/**
* ERP Input
*
*
*
* @param num
* @package
* @return
*/
export function erpNumberFormatter(
num: number | string | undefined,
digit: number,
) {
if (num === null || num === undefined) {
return '';
}
if (typeof num === 'string') {
num = Number.parseFloat(num);
}
// 如果非 number则直接返回空串
if (Number.isNaN(num)) {
return '';
}
return num.toFixed(digit);
}
/**
* ERP
*
*
*
* @param num
* @return
*/
export function erpCountInputFormatter(num: number | string | undefined) {
return erpNumberFormatter(num, ERP_COUNT_DIGIT);
}
// noinspection JSCommentMatchesSignature
/**
* ERP
*
* @param cellValue
* @return
*/
export function erpCountTableColumnFormatter(cellValue: any) {
return erpNumberFormatter(cellValue, ERP_COUNT_DIGIT);
}
/**
* ERP
*
*
*
* @param num
* @return
*/
export function erpPriceInputFormatter(num: number | string | undefined) {
return erpNumberFormatter(num, ERP_PRICE_DIGIT);
}
// noinspection JSCommentMatchesSignature
/**
* ERP
*
* @param cellValue
* @return
*/
export function erpPriceTableColumnFormatter(cellValue: any) {
return erpNumberFormatter(cellValue, ERP_PRICE_DIGIT);
}
/**
* ERP
*
* @param price
* @param count
* @return undefined
*/
export function erpPriceMultiply(price: number, count: number) {
if (price === null || count === null) {
return undefined;
}
return Number.parseFloat((price * count).toFixed(ERP_PRICE_DIGIT));
}
/**
* ERP
*
* total 0 0
*
* @param value
* @param total
*/
export function erpCalculatePercentage(value: number, total: number) {
if (total === 0) return 0;
return ((value / total) * 100).toFixed(2);
}

View File

@ -1,5 +1,13 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { formatDateTime } from '@vben/utils';
import { getBusinessStatusTypeSimpleList } from '#/api/crm/business/status';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getSimpleUserList } from '#/api/system/user';
import { erpPriceInputFormatter, erpPriceMultiply } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@ -19,18 +27,49 @@ export function useFormSchema(): VbenFormSchema[] {
rules: 'required',
},
{
fieldName: 'customerId',
label: '客户',
component: 'Input',
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleUserList(),
fieldNames: {
label: 'nickname',
value: 'id',
},
},
rules: 'required',
},
{
fieldName: 'totalPrice',
label: '商机金额',
component: 'InputNumber',
fieldName: 'customerId',
label: '客户名称',
component: 'ApiSelect',
componentProps: {
min: 0,
placeholder: '请输入商机金额',
api: () => getCustomerSimpleList(),
fieldNames: {
label: 'name',
value: 'id',
},
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => !values.customerId,
},
rules: 'required',
},
{
fieldName: 'statusTypeId',
label: '商机状态组',
component: 'ApiSelect',
componentProps: {
api: () => getBusinessStatusTypeSimpleList(),
fieldNames: {
label: 'name',
value: 'id',
},
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => !values.id,
},
rules: 'required',
},
@ -46,9 +85,43 @@ export function useFormSchema(): VbenFormSchema[] {
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
fieldName: 'totalProductPrice',
label: '产品总金额',
component: 'InputNumber',
componentProps: {
min: 0,
},
rules: 'required',
},
{
fieldName: 'discountPercent',
label: '整单折扣(%',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
rules: 'required',
},
{
fieldName: 'totalPrice',
label: '折扣后金额',
component: 'InputNumber',
dependencies: {
triggerFields: ['totalProductPrice', 'discountPercent'],
disabled: () => true,
trigger(values, form) {
const discountPrice =
erpPriceMultiply(
values.totalProductPrice,
values.discountPercent / 100,
) ?? 0;
form.setFieldValue(
'totalPrice',
values.totalProductPrice - discountPrice,
);
},
},
},
];
}
@ -143,3 +216,72 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'customerName',
label: '客户名称',
},
{
field: 'totalPrice',
label: '商机金额(元)',
content: (data) => erpPriceInputFormatter(data.totalPrice),
},
{
field: 'statusTypeName',
label: '商机组',
},
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'createTime',
label: '创建时间',
content: (data) => formatDateTime(data?.createTime) as string,
},
];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '商机名称',
},
{
field: 'customerName',
label: '客户名称',
},
{
field: 'totalPrice',
label: '商机金额(元)',
content: (data) => erpPriceInputFormatter(data.totalPrice),
},
{
field: 'dealTime',
label: '预计成交日期',
content: (data) => formatDateTime(data?.dealTime) as string,
},
{
field: 'contactNextTime',
label: '下次联系时间',
content: (data) => formatDateTime(data?.contactNextTime) as string,
},
{
field: 'statusTypeName',
label: '商机状态组',
},
{
field: 'statusName',
label: '商机阶段',
},
{
field: 'remark',
label: '备注',
},
];
}

View File

@ -1,4 +1,44 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import { Divider } from 'ant-design-vue';
import { useDescription } from '#/components/description';
import { useFollowUpDetailSchema } from '#/views/crm/followup/data';
import { useDetailBaseSchema } from '../data';
defineOptions({ name: 'CrmCustomerDetailsInfo' });
defineProps<{
business: CrmBusinessApi.Business; //
}>();
const [BaseDescription] = useDescription({
componentProps: {
title: '基本信息',
bordered: false,
column: 4,
class: 'mx-4',
},
schema: useDetailBaseSchema(),
});
const [SystemDescription] = useDescription({
componentProps: {
title: '系统信息',
bordered: false,
column: 3,
class: 'mx-4',
},
schema: useFollowUpDetailSchema(),
});
</script>
<template>
<div>businessInfo</div>
<div class="p-4">
<BaseDescription :data="business" />
<Divider />
<SystemDescription :data="business" />
</div>
</template>

View File

@ -1,7 +1,188 @@
<script lang="ts" setup></script>
<script setup lang="ts">
import type { CrmBusinessApi } from '#/api/crm/business';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { defineAsyncComponent, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { Button, Card, Tabs } from 'ant-design-vue';
import { getBusiness } from '#/api/crm/business';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const BusinessDetailsInfo = defineAsyncComponent(
() => import('./detail-info.vue'),
);
const FollowUp = defineAsyncComponent(
() => import('#/views/crm/followup/index.vue'),
);
const PermissionList = defineAsyncComponent(
() => import('#/views/crm/permission/modules/permission-list.vue'),
);
const TransferForm = defineAsyncComponent(
() => import('#/views/crm/permission/modules/transfer-form.vue'),
);
const OperateLog = defineAsyncComponent(
() => import('#/components/operate-log'),
);
const BusinessForm = defineAsyncComponent(
() => import('#/views/crm/business/modules/form.vue'),
);
const UpStatusForm = defineAsyncComponent(
() => import('#/views/crm/business/modules/up-status-form.vue'),
);
const loading = ref(false);
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const businessId = ref(0);
const business = ref<CrmBusinessApi.Business>({} as CrmBusinessApi.Business);
const businessLogList = ref<SystemOperateLogApi.OperateLog[]>([]);
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // Ref
const [Description] = useDescription({
componentProps: {
bordered: false,
column: 4,
class: 'mx-4',
},
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: BusinessForm,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
const [UpStatusModal, upStatusModalApi] = useVbenModal({
connectedComponent: UpStatusForm,
destroyOnClose: true,
});
/** 加载详情 */
async function loadBusinessDetail() {
loading.value = true;
const data = await getBusiness(businessId.value);
const logList = await getOperateLogPage({
bizType: BizTypeEnum.CRM_BUSINESS,
bizId: businessId.value,
});
businessLogList.value = logList.list;
business.value = data;
loading.value = false;
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push('/crm/business');
}
/** 编辑 */
function handleEdit() {
formModalApi.setData({ id: businessId.value }).open();
}
/** 转移线索 */
function handleTransfer() {
transferModalApi.setData({ bizType: BizTypeEnum.CRM_BUSINESS }).open();
}
/** 更新商机状态操作 */
async function handleUpdateStatus() {
upStatusModalApi.setData(business.value).open();
}
//
onMounted(async () => {
businessId.value = Number(route.params.id);
await loadBusinessDetail();
});
</script>
<template>
<div>
<p>待完成</p>
</div>
<Page auto-content-height :title="business?.name" :loading="loading">
<FormModal @success="loadBusinessDetail" />
<TransferModal @success="loadBusinessDetail" />
<UpStatusModal @success="loadBusinessDetail" />
<template #extra>
<div class="flex items-center gap-2">
<Button
v-if="permissionListRef?.validateWrite"
type="primary"
@click="handleEdit"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
v-if="permissionListRef?.validateWrite"
@click="handleUpdateStatus"
>
变更商机状态
</Button>
<Button
v-if="permissionListRef?.validateOwnerUser"
@click="handleTransfer"
>
转移
</Button>
</div>
</template>
<Card class="min-h-[10%]">
<Description :data="business" />
</Card>
<Card class="mt-4 min-h-[60%]">
<Tabs>
<Tabs.TabPane tab="详细资料" key="1" :force-render="true">
<BusinessDetailsInfo :business="business" />
</Tabs.TabPane>
<Tabs.TabPane tab="跟进记录" key="2" :force-render="true">
<FollowUp :biz-id="businessId" :biz-type="BizTypeEnum.CRM_BUSINESS" />
</Tabs.TabPane>
<Tabs.TabPane tab="联系人" key="3" :force-render="true">
<div>联系人</div>
</Tabs.TabPane>
<Tabs.TabPane tab="产品" key="4" :force-render="true">
<div>产品</div>
</Tabs.TabPane>
<Tabs.TabPane tab="合同" key="5" :force-render="true">
<div>合同</div>
</Tabs.TabPane>
<Tabs.TabPane tab="团队成员" key="6" :force-render="true">
<PermissionList
ref="permissionListRef"
:biz-id="businessId"
:biz-type="BizTypeEnum.CRM_BUSINESS"
:show-action="true"
@quit-team="handleBack"
/>
</Tabs.TabPane>
<Tabs.TabPane tab="操作日志" key="7" :force-render="true">
<OperateLog :log-list="businessLogList" />
</Tabs.TabPane>
</Tabs>
</Card>
</Page>
</template>

View File

@ -0,0 +1,140 @@
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateBusinessStatus } from '#/api/crm/business';
import {
DEFAULT_STATUSES,
getBusinessStatusSimpleList,
} from '#/api/crm/business/status';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessApi.Business>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'statusId',
label: '商机状态',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'endStatus',
label: '商机状态',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'status',
label: '商机阶段',
component: 'Select',
dependencies: {
triggerFields: [''],
async componentProps() {
const statusList = await getBusinessStatusSimpleList(
formData.value?.statusTypeId ?? 0,
);
const statusOptions = statusList.map((item) => ({
label: `${item.name}(赢单率:${item.percent}%)`,
value: item.id,
}));
const options = DEFAULT_STATUSES.map((item) => ({
label: `${`${item.name}(赢单率:${item.percent}`}%)`,
value: item.endStatus,
}));
statusOptions.push(...options);
return {
options: statusOptions,
};
},
},
rules: 'required',
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as CrmBusinessApi.Business;
try {
if (!data.status) {
return;
}
await updateBusinessStatus({
id: data.id,
statusId: data.status > 0 ? data.status : undefined,
endStatus: data.status < 0 ? -data.status : undefined,
});
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
//
const data = modalApi.getData<CrmBusinessApi.Business>();
if (!data || !data.id) {
return;
}
data.status = data.endStatus === null ? data.statusId : -data.endStatus;
formData.value = data;
modalApi.lock();
try {
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal title="变更商机状态" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -18,7 +18,7 @@ import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessStatusApi.BusinessStatusType>();
const formData = ref<CrmBusinessStatusApi.BusinessStatus>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商机状态'])
@ -47,7 +47,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
//
const data =
(await formApi.getValues()) as CrmBusinessStatusApi.BusinessStatusType;
(await formApi.getValues()) as CrmBusinessStatusApi.BusinessStatus;
try {
await (formData.value?.id
? updateBusinessStatus(data)
@ -66,7 +66,7 @@ const [Modal, modalApi] = useVbenModal({
return;
}
//
const data = modalApi.getData<CrmBusinessStatusApi.BusinessStatusType>();
const data = modalApi.getData<CrmBusinessStatusApi.BusinessStatus>();
if (!data || !data.id) {
return;
}

View File

@ -212,21 +212,21 @@ onMounted(async () => {
</Button>
</div>
</template>
<Card>
<Card class="min-h-[10%]">
<Description :data="customer" />
</Card>
<Card class="mt-4">
<Card class="mt-4 min-h-[60%]">
<Tabs>
<Tabs.TabPane tab="基本信息" key="1">
<Tabs.TabPane tab="基本信息" key="1" :force-render="true">
<CustomerDetailsInfo :customer="customer" />
</Tabs.TabPane>
<Tabs.TabPane tab="跟进记录" key="2">
<Tabs.TabPane tab="跟进记录" key="2" :force-render="true">
<FollowUp :biz-id="customerId" :biz-type="BizTypeEnum.CRM_CUSTOMER" />
</Tabs.TabPane>
<Tabs.TabPane tab="联系人" key="3">
<Tabs.TabPane tab="联系人" key="3" :force-render="true">
<div>联系人</div>
</Tabs.TabPane>
<Tabs.TabPane tab="团队成员" key="4">
<Tabs.TabPane tab="团队成员" key="4" :force-render="true">
<PermissionList
ref="permissionListRef"
:biz-id="customerId"
@ -235,16 +235,16 @@ onMounted(async () => {
@quit-team="handleBack"
/>
</Tabs.TabPane>
<Tabs.TabPane tab="商机" key="5">
<Tabs.TabPane tab="商机" key="5" :force-render="true">
<div>商机</div>
</Tabs.TabPane>
<Tabs.TabPane tab="合同" key="6">
<Tabs.TabPane tab="合同" key="6" :force-render="true">
<div>合同</div>
</Tabs.TabPane>
<Tabs.TabPane tab="回款" key="7">
<Tabs.TabPane tab="回款" key="7" :force-render="true">
<div>回款</div>
</Tabs.TabPane>
<Tabs.TabPane tab="操作日志" key="8">
<Tabs.TabPane tab="操作日志" key="8" :force-render="true">
<OperateLog :log-list="customerLogList" />
</Tabs.TabPane>
</Tabs>