新增已丢单页面以及导出功能的调整
parent
9453229055
commit
80eb1a493f
|
|
@ -5,11 +5,14 @@
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
|
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OpportunityService.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/OpportunityService.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/frontend/src/lib/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/lib/auth.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
|
@ -103,6 +106,7 @@
|
||||||
<workItem from="1776219416113" duration="650000" />
|
<workItem from="1776219416113" duration="650000" />
|
||||||
<workItem from="1776238420843" duration="21000" />
|
<workItem from="1776238420843" duration="21000" />
|
||||||
<workItem from="1787113214298" duration="3043000" />
|
<workItem from="1787113214298" duration="3043000" />
|
||||||
|
<workItem from="1787211862072" duration="4949000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,10 @@ public class OpportunityController {
|
||||||
@RequestParam(value = "stage", required = false) String stage,
|
@RequestParam(value = "stage", required = false) String stage,
|
||||||
@RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails,
|
@RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit,
|
@RequestParam(value = "limit", required = false) Integer limit,
|
||||||
@RequestParam(value = "archived", required = false) Boolean archived) {
|
@RequestParam(value = "archived", required = false) Boolean archived,
|
||||||
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage, includeDetails, limit, archived));
|
@RequestParam(value = "excludeStageCodes", required = false) List<String> excludeStageCodes,
|
||||||
|
@RequestParam(value = "stageCodes", required = false) List<String> stageCodes) {
|
||||||
|
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage, includeDetails, limit, archived, excludeStageCodes, stageCodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{opportunityId}")
|
@GetMapping("/{opportunityId}")
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ public interface OpportunityMapper {
|
||||||
@Param("preSalesUserId") Long preSalesUserId,
|
@Param("preSalesUserId") Long preSalesUserId,
|
||||||
@Param("preSalesUserNames") List<String> preSalesUserNames,
|
@Param("preSalesUserNames") List<String> preSalesUserNames,
|
||||||
@Param("limit") Integer limit,
|
@Param("limit") Integer limit,
|
||||||
@Param("archived") Boolean archived);
|
@Param("archived") Boolean archived,
|
||||||
|
@Param("excludeStageCodes") List<String> excludeStageCodes,
|
||||||
|
@Param("stageCodes") List<String> stageCodes);
|
||||||
|
|
||||||
OpportunityItemDTO selectOpportunityDetail(
|
OpportunityItemDTO selectOpportunityDetail(
|
||||||
@Param("userId") Long userId,
|
@Param("userId") Long userId,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ public interface OpportunityService {
|
||||||
|
|
||||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived);
|
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived);
|
||||||
|
|
||||||
|
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived, List<String> excludeStageCodes, List<String> stageCodes);
|
||||||
|
|
||||||
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
|
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
|
||||||
|
|
||||||
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
|
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived) {
|
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived) {
|
||||||
|
return getOverview(userId, keyword, stage, includeDetails, limit, archived, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived, List<String> excludeStageCodes, List<String> stageCodes) {
|
||||||
String normalizedKeyword = normalizeKeyword(keyword);
|
String normalizedKeyword = normalizeKeyword(keyword);
|
||||||
String normalizedStage = normalizeStage(stage);
|
String normalizedStage = normalizeStage(stage);
|
||||||
OpportunityVisibility visibility = resolveOpportunityVisibility(userId);
|
OpportunityVisibility visibility = resolveOpportunityVisibility(userId);
|
||||||
|
|
@ -156,7 +161,9 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
visibility.preSalesUserId(),
|
visibility.preSalesUserId(),
|
||||||
visibility.preSalesUserNames(),
|
visibility.preSalesUserNames(),
|
||||||
limit != null && limit > 0 ? Math.min(limit, 1000) : null,
|
limit != null && limit > 0 ? Math.min(limit, 1000) : null,
|
||||||
archived);
|
archived,
|
||||||
|
excludeStageCodes,
|
||||||
|
stageCodes);
|
||||||
if (includeDetails) {
|
if (includeDetails) {
|
||||||
attachFollowUps(userId, items, visibility);
|
attachFollowUps(userId, items, visibility);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,13 @@
|
||||||
<if test="archived != null">
|
<if test="archived != null">
|
||||||
and coalesce(o.archived, false) = #{archived}
|
and coalesce(o.archived, false) = #{archived}
|
||||||
</if>
|
</if>
|
||||||
|
<if test="excludeStageCodes != null and excludeStageCodes.size() > 0">
|
||||||
|
and coalesce(o.stage, '') not in (
|
||||||
|
<foreach item="code" collection="excludeStageCodes" open="(" separator="," close=")">
|
||||||
|
#{code}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
<if test="keyword != null and keyword != ''">
|
<if test="keyword != null and keyword != ''">
|
||||||
and (
|
and (
|
||||||
o.opportunity_name ilike concat('%', #{keyword}, '%')
|
o.opportunity_name ilike concat('%', #{keyword}, '%')
|
||||||
|
|
@ -277,6 +284,13 @@
|
||||||
<if test="stage != null and stage != ''">
|
<if test="stage != null and stage != ''">
|
||||||
and o.stage = #{stage}
|
and o.stage = #{stage}
|
||||||
</if>
|
</if>
|
||||||
|
<if test="stageCodes != null and stageCodes.size() > 0">
|
||||||
|
and o.stage in (
|
||||||
|
<foreach item="code" collection="stageCodes" open="(" separator="," close=")">
|
||||||
|
#{code}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
<include refid="opportunityVisibilityCondition"/>
|
<include refid="opportunityVisibilityCondition"/>
|
||||||
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
|
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
|
||||||
<if test="limit != null and limit > 0">
|
<if test="limit != null and limit > 0">
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,8 @@ class OpportunityServiceImplTest {
|
||||||
eq(9L),
|
eq(9L),
|
||||||
eq(List.of("张售前", "presales.zhang")),
|
eq(List.of("张售前", "presales.zhang")),
|
||||||
eq(null),
|
eq(null),
|
||||||
|
eq(null),
|
||||||
|
eq(null),
|
||||||
eq(null))).thenReturn(List.of(item));
|
eq(null))).thenReturn(List.of(item));
|
||||||
when(opportunityMapper.selectOpportunityFollowUps(
|
when(opportunityMapper.selectOpportunityFollowUps(
|
||||||
eq(9L),
|
eq(9L),
|
||||||
|
|
|
||||||
|
|
@ -1620,7 +1620,7 @@ export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload)
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOpportunityOverview(keyword?: string, stage?: string, includeDetails = true, limit?: number, archived?: boolean) {
|
export async function getOpportunityOverview(keyword?: string, stage?: string, includeDetails = true, limit?: number, archived?: boolean, excludeStageCodes?: string[], stageCodes?: string[]) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (keyword && keyword.trim()) {
|
if (keyword && keyword.trim()) {
|
||||||
params.set("keyword", keyword.trim());
|
params.set("keyword", keyword.trim());
|
||||||
|
|
@ -1629,12 +1629,18 @@ export async function getOpportunityOverview(keyword?: string, stage?: string, i
|
||||||
params.set("stage", stage.trim());
|
params.set("stage", stage.trim());
|
||||||
}
|
}
|
||||||
params.set("includeDetails", String(includeDetails));
|
params.set("includeDetails", String(includeDetails));
|
||||||
if (limit !== undefined) {
|
if (limit != null) {
|
||||||
params.set("limit", String(limit));
|
params.set("limit", String(limit));
|
||||||
}
|
}
|
||||||
if (archived !== undefined) {
|
if (archived !== undefined) {
|
||||||
params.set("archived", String(archived));
|
params.set("archived", String(archived));
|
||||||
}
|
}
|
||||||
|
if (excludeStageCodes && excludeStageCodes.length > 0) {
|
||||||
|
excludeStageCodes.forEach((code) => params.append("excludeStageCodes", code));
|
||||||
|
}
|
||||||
|
if (stageCodes && stageCodes.length > 0) {
|
||||||
|
stageCodes.forEach((code) => params.append("stageCodes", code));
|
||||||
|
}
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
|
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
|
||||||
}
|
}
|
||||||
|
|
@ -1685,7 +1691,7 @@ export async function getExpansionOverview(keyword?: string, includeDetails = tr
|
||||||
params.set("keyword", keyword.trim());
|
params.set("keyword", keyword.trim());
|
||||||
}
|
}
|
||||||
params.set("includeDetails", String(includeDetails));
|
params.set("includeDetails", String(includeDetails));
|
||||||
if (limit !== undefined) {
|
if (limit != null) {
|
||||||
params.set("limit", String(limit));
|
params.set("limit", String(limit));
|
||||||
}
|
}
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
|
|
@ -1697,7 +1703,7 @@ export async function getOpportunityExpansionOptions(params?: { keyword?: string
|
||||||
if (params?.keyword && params.keyword.trim()) {
|
if (params?.keyword && params.keyword.trim()) {
|
||||||
searchParams.set("keyword", params.keyword.trim());
|
searchParams.set("keyword", params.keyword.trim());
|
||||||
}
|
}
|
||||||
if (params?.limit !== undefined) {
|
if (params?.limit != null) {
|
||||||
searchParams.set("limit", String(params.limit));
|
searchParams.set("limit", String(params.limit));
|
||||||
}
|
}
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ const COMPETITOR_OPTIONS = [
|
||||||
|
|
||||||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||||
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
||||||
type OpportunityArchiveTab = "active" | "archived";
|
type OpportunityArchiveTab = "active" | "archived" | "lost";
|
||||||
type OpportunityLocationState = { selectedId?: CrmId; archiveTab?: OpportunityArchiveTab } | null;
|
type OpportunityLocationState = { selectedId?: CrmId; archiveTab?: OpportunityArchiveTab } | null;
|
||||||
type OpportunityExportFilters = {
|
type OpportunityExportFilters = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
|
@ -444,19 +444,45 @@ function getOpportunityDictOptionLabel(option?: OpportunityDictOption) {
|
||||||
return option?.label?.trim() || option?.value?.trim() || "";
|
return option?.label?.trim() || option?.value?.trim() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLostOpportunityStageOption(option?: OpportunityDictOption) {
|
function isLostOpportunityStageOption(option?: { value?: string; label?: string; stageCode?: string; stage?: string }) {
|
||||||
const value = getOpportunityDictOptionValue(option).toLowerCase();
|
const value = (option?.value?.trim() || option?.label?.trim() || option?.stageCode?.trim() || option?.stage?.trim() || "").toLowerCase();
|
||||||
const label = getOpportunityDictOptionLabel(option).toLowerCase();
|
const label = (option?.label?.trim() || option?.value?.trim() || option?.stage?.trim() || option?.stageCode?.trim() || "").toLowerCase();
|
||||||
return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃");
|
return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultOpportunityExportStageCodes(options: OpportunityDictOption[]) {
|
function getLostStageCodes(stageOptions: OpportunityDictOption[]) {
|
||||||
|
return stageOptions
|
||||||
|
.filter(isLostOpportunityStageOption)
|
||||||
|
.map((opt) => getOpportunityDictOptionValue(opt))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultOpportunityExportStageCodes(options: OpportunityDictOption[], archiveTab?: OpportunityArchiveTab) {
|
||||||
const orderedValues = options.map(getOpportunityDictOptionValue).filter(Boolean);
|
const orderedValues = options.map(getOpportunityDictOptionValue).filter(Boolean);
|
||||||
|
if (archiveTab === "lost") {
|
||||||
|
const lostValues = options.filter(isLostOpportunityStageOption).map(getOpportunityDictOptionValue).filter(Boolean);
|
||||||
|
return normalizeOpportunityMultiSelectValues(lostValues.length > 0 ? lostValues : orderedValues);
|
||||||
|
}
|
||||||
const preferredValues = options.filter((option) => !isLostOpportunityStageOption(option)).map(getOpportunityDictOptionValue).filter(Boolean);
|
const preferredValues = options.filter((option) => !isLostOpportunityStageOption(option)).map(getOpportunityDictOptionValue).filter(Boolean);
|
||||||
return normalizeOpportunityMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues);
|
return normalizeOpportunityMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFilters, stageOptions: OpportunityDictOption[]) {
|
function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFilters, stageOptions: OpportunityDictOption[], archiveTab?: OpportunityArchiveTab) {
|
||||||
|
if (archiveTab === "lost") {
|
||||||
|
if (filters.stageCodes === undefined) {
|
||||||
|
const lostValues = stageOptions.filter(isLostOpportunityStageOption).map(getOpportunityDictOptionValue).filter(Boolean);
|
||||||
|
return {
|
||||||
|
...filters,
|
||||||
|
stage: undefined,
|
||||||
|
stageCodes: normalizeOpportunityMultiSelectValues(lostValues),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...filters,
|
||||||
|
stage: undefined,
|
||||||
|
stageCodes: normalizeOpportunityMultiSelectValues(filters.stageCodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (filters.stageCodes !== undefined) {
|
if (filters.stageCodes !== undefined) {
|
||||||
return {
|
return {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
@ -481,7 +507,7 @@ function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFil
|
||||||
return {
|
return {
|
||||||
...filters,
|
...filters,
|
||||||
stage: undefined,
|
stage: undefined,
|
||||||
stageCodes: getDefaultOpportunityExportStageCodes(stageOptions),
|
stageCodes: getDefaultOpportunityExportStageCodes(stageOptions, archiveTab),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -970,11 +996,12 @@ function OpportunityExportFilterModal({
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (filters: OpportunityExportFilters) => void;
|
onConfirm: (filters: OpportunityExportFilters) => void;
|
||||||
}) {
|
}) {
|
||||||
const normalizedInitialFilters = applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions);
|
const normalizedInitialFilters = applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions, archiveTab);
|
||||||
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(normalizedInitialFilters);
|
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(normalizedInitialFilters);
|
||||||
const selectedFields = resolveSelectedOpportunityFields(draftFilters.selectedFields);
|
const selectedFields = resolveSelectedOpportunityFields(draftFilters.selectedFields);
|
||||||
const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions);
|
const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions, archiveTab);
|
||||||
const exportStageOptions = stageOptions
|
const exportStageOptions = stageOptions
|
||||||
|
.filter((option) => archiveTab === "lost" || archiveTab === "archived" || !isLostOpportunityStageOption(option))
|
||||||
.map((option) => {
|
.map((option) => {
|
||||||
const value = getOpportunityDictOptionValue(option);
|
const value = getOpportunityDictOptionValue(option);
|
||||||
const label = getOpportunityDictOptionLabel(option);
|
const label = getOpportunityDictOptionLabel(option);
|
||||||
|
|
@ -984,7 +1011,7 @@ function OpportunityExportFilterModal({
|
||||||
const selectedStageCodes = normalizeOpportunityMultiSelectValues(draftFilters.stageCodes);
|
const selectedStageCodes = normalizeOpportunityMultiSelectValues(draftFilters.stageCodes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftFilters(applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions));
|
setDraftFilters(applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions, archiveTab));
|
||||||
}, [initialFilters, stageOptions]);
|
}, [initialFilters, stageOptions]);
|
||||||
|
|
||||||
const hasDraftFilters = Boolean(
|
const hasDraftFilters = Boolean(
|
||||||
|
|
@ -997,7 +1024,7 @@ function OpportunityExportFilterModal({
|
||||||
|| draftFilters.operatorName
|
|| draftFilters.operatorName
|
||||||
|| draftFilters.hasSalesExpansion
|
|| draftFilters.hasSalesExpansion
|
||||||
|| draftFilters.hasChannelExpansion,
|
|| draftFilters.hasChannelExpansion,
|
||||||
) || !areSameOpportunityStringSets(selectedStageCodes, defaultStageCodes)
|
) || (archiveTab !== "lost" && !areSameOpportunityStringSets(selectedStageCodes, defaultStageCodes))
|
||||||
|| JSON.stringify(selectedFields) !== JSON.stringify(defaultOpportunityExportFields);
|
|| JSON.stringify(selectedFields) !== JSON.stringify(defaultOpportunityExportFields);
|
||||||
const hasSelectedFields = selectedFields.length > 0;
|
const hasSelectedFields = selectedFields.length > 0;
|
||||||
const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => {
|
const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => {
|
||||||
|
|
@ -1044,15 +1071,15 @@ function OpportunityExportFilterModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalShell
|
<ModalShell
|
||||||
title={`导出${archiveTab === "active" ? "未签单" : "已签单"}商机`}
|
title={`导出${archiveTab === "active" ? "未签单" : archiveTab === "lost" ? "已丢单" : "已签单"}商机`}
|
||||||
subtitle="选择条件后导出 Excel;不填条件则导出当前签单页签下的全部可见商机。"
|
subtitle="选择条件后导出 Excel;不填条件则导出当前页签下的全部可见商机。"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
footer={(
|
footer={(
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDraftFilters((current) => ({
|
onClick={() => setDraftFilters((current) => ({
|
||||||
...applyDefaultOpportunityExportStageFilters({}, stageOptions),
|
...applyDefaultOpportunityExportStageFilters({}, stageOptions, archiveTab),
|
||||||
selectedFields: resolveSelectedOpportunityFields(current.selectedFields),
|
selectedFields: resolveSelectedOpportunityFields(current.selectedFields),
|
||||||
}))}
|
}))}
|
||||||
disabled={!hasDraftFilters}
|
disabled={!hasDraftFilters}
|
||||||
|
|
@ -1099,6 +1126,7 @@ function OpportunityExportFilterModal({
|
||||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{archiveTab !== "lost" ? (
|
||||||
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
|
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1157,6 +1185,7 @@ function OpportunityExportFilterModal({
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">未加载到阶段字典,导出时不会按项目阶段过滤。</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">未加载到阶段字典,导出时不会按项目阶段过滤。</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<label className="space-y-1.5">
|
<label className="space-y-1.5">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目把握度</span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目把握度</span>
|
||||||
<AdaptiveSelect
|
<AdaptiveSelect
|
||||||
|
|
@ -1991,7 +2020,12 @@ export default function Opportunities() {
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const data = await getOpportunityOverview(keyword, filter, false, LIST_PAGE_SIZE + 1, archiveTab === "archived");
|
const lostStageCodes = getLostStageCodes(stageOptions);
|
||||||
|
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
|
||||||
|
const effectiveStage = archiveTab === "lost" ? undefined : filter;
|
||||||
|
const effectiveArchived = archiveTab === "archived";
|
||||||
|
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
|
||||||
|
const data = await getOpportunityOverview(keyword, effectiveStage, false, LIST_PAGE_SIZE + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setItems(data.items ?? []);
|
setItems(data.items ?? []);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
|
|
@ -2008,16 +2042,20 @@ export default function Opportunities() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [keyword, filter, archiveTab]);
|
}, [keyword, filter, archiveTab, stageOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleItemCount(LIST_PAGE_SIZE);
|
setVisibleItemCount(LIST_PAGE_SIZE);
|
||||||
}, [keyword, filter, archiveTab]);
|
const lostCodes = getLostStageCodes(stageOptions);
|
||||||
|
if (archiveTab === "active" && lostCodes.includes(filter)) {
|
||||||
|
setFilter("全部");
|
||||||
|
}
|
||||||
|
}, [keyword, filter, archiveTab, stageOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestedState = location.state as OpportunityLocationState;
|
const requestedState = location.state as OpportunityLocationState;
|
||||||
const requestedArchiveTab = requestedState?.archiveTab;
|
const requestedArchiveTab = requestedState?.archiveTab;
|
||||||
if (requestedArchiveTab === "active" || requestedArchiveTab === "archived") {
|
if (requestedArchiveTab === "active" || requestedArchiveTab === "archived" || requestedArchiveTab === "lost") {
|
||||||
setArchiveTab(requestedArchiveTab);
|
setArchiveTab(requestedArchiveTab);
|
||||||
}
|
}
|
||||||
}, [location.state]);
|
}, [location.state]);
|
||||||
|
|
@ -2219,10 +2257,14 @@ export default function Opportunities() {
|
||||||
}
|
}
|
||||||
setExportFilters((current) => (
|
setExportFilters((current) => (
|
||||||
current.stageCodes === undefined
|
current.stageCodes === undefined
|
||||||
? applyDefaultOpportunityExportStageFilters(current, stageOptions)
|
? applyDefaultOpportunityExportStageFilters(current, stageOptions, archiveTab)
|
||||||
: current
|
: current
|
||||||
));
|
));
|
||||||
}, [stageOptions]);
|
}, [stageOptions, archiveTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExportFilters((current) => ({ ...current, stageCodes: undefined }));
|
||||||
|
}, [archiveTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opportunityTypeOptions.length) {
|
if (!opportunityTypeOptions.length) {
|
||||||
|
|
@ -2319,7 +2361,10 @@ export default function Opportunities() {
|
||||||
|
|
||||||
const detailItem = selectedItem ? (selectedItemDetail ?? selectedItem) : null;
|
const detailItem = selectedItem ? (selectedItemDetail ?? selectedItem) : null;
|
||||||
const followUpRecords: OpportunityFollowUp[] = detailItem?.followUps ?? [];
|
const followUpRecords: OpportunityFollowUp[] = detailItem?.followUps ?? [];
|
||||||
const filteredItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
|
const filteredItems = items.filter((item) => {
|
||||||
|
if (archiveTab === "lost") return !item.archived && isLostOpportunityStageOption(item);
|
||||||
|
return archiveTab === "active" ? !item.archived && !isLostOpportunityStageOption(item) : Boolean(item.archived);
|
||||||
|
});
|
||||||
const visibleItems = filteredItems.slice(0, visibleItemCount);
|
const visibleItems = filteredItems.slice(0, visibleItemCount);
|
||||||
const hasMoreItems = visibleItems.length < filteredItems.length;
|
const hasMoreItems = visibleItems.length < filteredItems.length;
|
||||||
|
|
||||||
|
|
@ -2330,7 +2375,12 @@ export default function Opportunities() {
|
||||||
loadingMoreRef.current = true;
|
loadingMoreRef.current = true;
|
||||||
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
|
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
|
||||||
try {
|
try {
|
||||||
const data = await getOpportunityOverview(keyword, filter, false, nextVisibleCount + 1, archiveTab === "archived");
|
const lostStageCodes = getLostStageCodes(stageOptions);
|
||||||
|
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
|
||||||
|
const effectiveStage = archiveTab === "lost" ? undefined : filter;
|
||||||
|
const effectiveArchived = archiveTab === "archived";
|
||||||
|
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
|
||||||
|
const data = await getOpportunityOverview(keyword, effectiveStage, false, nextVisibleCount + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
|
||||||
setItems(data.items ?? []);
|
setItems(data.items ?? []);
|
||||||
setVisibleItemCount(nextVisibleCount);
|
setVisibleItemCount(nextVisibleCount);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -2355,7 +2405,9 @@ export default function Opportunities() {
|
||||||
}, [hasMoreItems, visibleItemCount, filteredItems.length, keyword, filter, archiveTab]);
|
}, [hasMoreItems, visibleItemCount, filteredItems.length, keyword, filter, archiveTab]);
|
||||||
const stageFilterOptions = [
|
const stageFilterOptions = [
|
||||||
{ label: "全部", value: "全部" },
|
{ label: "全部", value: "全部" },
|
||||||
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
...stageOptions
|
||||||
|
.filter((item) => archiveTab === "lost" || archiveTab === "archived" || !isLostOpportunityStageOption(item))
|
||||||
|
.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
||||||
].filter((item) => item.value);
|
].filter((item) => item.value);
|
||||||
const normalizedProjectLocation = form.projectLocation?.trim() || "";
|
const normalizedProjectLocation = form.projectLocation?.trim() || "";
|
||||||
const normalizedProjectOwnershipLocation = form.projectOwnershipLocation?.trim() || "";
|
const normalizedProjectOwnershipLocation = form.projectOwnershipLocation?.trim() || "";
|
||||||
|
|
@ -2515,20 +2567,28 @@ export default function Opportunities() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedFilters = applyDefaultOpportunityExportStageFilters(filters, stageOptions);
|
const normalizedFilters = applyDefaultOpportunityExportStageFilters(filters, stageOptions, archiveTab);
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportError("");
|
setExportError("");
|
||||||
setExportFilters(normalizedFilters);
|
setExportFilters(normalizedFilters);
|
||||||
persistOpportunityExportPreferences(normalizedFilters);
|
persistOpportunityExportPreferences(normalizedFilters);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const overview = await getOpportunityOverview("", "全部");
|
const lostStageCodes = getLostStageCodes(stageOptions);
|
||||||
|
const exportStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
|
||||||
|
const exportArchived = archiveTab === "archived";
|
||||||
|
const exportExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
|
||||||
|
const overview = await getOpportunityOverview("", undefined, false, null, exportArchived, exportExcludeStageCodes, exportStageCodes);
|
||||||
const exportItems = (overview.items ?? [])
|
const exportItems = (overview.items ?? [])
|
||||||
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
|
.filter((item) => {
|
||||||
|
if (archiveTab === "lost") return !item.archived && isLostOpportunityStageOption(item);
|
||||||
|
return archiveTab === "active" ? !item.archived && !isLostOpportunityStageOption(item) : Boolean(item.archived);
|
||||||
|
})
|
||||||
.filter((item) => matchesOpportunityExportFilters(item, normalizedFilters, effectiveConfidenceOptions));
|
.filter((item) => matchesOpportunityExportFilters(item, normalizedFilters, effectiveConfidenceOptions));
|
||||||
const selectedFieldKeys = resolveSelectedOpportunityFields(normalizedFilters.selectedFields);
|
const selectedFieldKeys = resolveSelectedOpportunityFields(normalizedFilters.selectedFields);
|
||||||
|
const exportTabLabel = archiveTab === "active" ? "未签单" : archiveTab === "lost" ? "已丢单" : "已签单";
|
||||||
if (exportItems.length <= 0) {
|
if (exportItems.length <= 0) {
|
||||||
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未签单" : "已签单"}商机`);
|
throw new Error(`当前筛选条件下暂无可导出的${exportTabLabel}商机`);
|
||||||
}
|
}
|
||||||
if (selectedFieldKeys.length <= 0) {
|
if (selectedFieldKeys.length <= 0) {
|
||||||
throw new Error("请至少选择一个导出字段");
|
throw new Error("请至少选择一个导出字段");
|
||||||
|
|
@ -2615,7 +2675,7 @@ export default function Opportunities() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const buffer = await workbook.xlsx.writeBuffer();
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
const filename = `商机储备_${archiveTab === "active" ? "未签单" : "已签单"}_${formatOpportunityExportFilenameTime()}.xlsx`;
|
const filename = `商机储备_${exportTabLabel}_${formatOpportunityExportFilenameTime()}.xlsx`;
|
||||||
downloadOpportunityExcelFile(filename, buffer);
|
downloadOpportunityExcelFile(filename, buffer);
|
||||||
setExportFilterOpen(false);
|
setExportFilterOpen(false);
|
||||||
} catch (exportErr) {
|
} catch (exportErr) {
|
||||||
|
|
@ -2839,7 +2899,12 @@ export default function Opportunities() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const reload = async (preferredSelectedId?: CrmId) => {
|
const reload = async (preferredSelectedId?: CrmId) => {
|
||||||
const data = await getOpportunityOverview(keyword, filter, false);
|
const lostStageCodes = getLostStageCodes(stageOptions);
|
||||||
|
const effectiveStageCodes = archiveTab === "lost" ? lostStageCodes : undefined;
|
||||||
|
const effectiveStage = archiveTab === "lost" ? undefined : filter;
|
||||||
|
const effectiveArchived = archiveTab === "archived";
|
||||||
|
const effectiveExcludeStageCodes = archiveTab === "active" ? lostStageCodes : undefined;
|
||||||
|
const data = await getOpportunityOverview(keyword, effectiveStage, false, LIST_PAGE_SIZE + 1, effectiveArchived, effectiveExcludeStageCodes, effectiveStageCodes);
|
||||||
const nextItems = data.items ?? [];
|
const nextItems = data.items ?? [];
|
||||||
setItems(nextItems);
|
setItems(nextItems);
|
||||||
if (preferredSelectedId) {
|
if (preferredSelectedId) {
|
||||||
|
|
@ -3046,7 +3111,7 @@ export default function Opportunities() {
|
||||||
|
|
||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
<div className="crm-empty-panel">
|
<div className="crm-empty-panel">
|
||||||
{archiveTab === "active" ? "暂无未签单商机,先新增一条试试。" : "暂无已签单商机。"}
|
{archiveTab === "active" ? "暂无未签单商机,先新增一条试试。" : archiveTab === "lost" ? "暂无已丢单商机。" : "暂无已签单商机。"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3107,6 +3172,19 @@ export default function Opportunities() {
|
||||||
>
|
>
|
||||||
已签单
|
已签单
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setArchiveTab("lost");
|
||||||
|
setExportError("");
|
||||||
|
}}
|
||||||
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
|
archiveTab === "lost"
|
||||||
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
已丢单
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -3123,6 +3201,7 @@ export default function Opportunities() {
|
||||||
className="crm-input-text w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white"
|
className="crm-input-text w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{archiveTab !== "lost" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStageFilterOpen(true)}
|
onClick={() => setStageFilterOpen(true)}
|
||||||
|
|
@ -3137,6 +3216,7 @@ export default function Opportunities() {
|
||||||
<ListFilter className="h-5 w-5" />
|
<ListFilter className="h-5 w-5" />
|
||||||
{filter !== "全部" ? <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-violet-500" /> : null}
|
{filter !== "全部" ? <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-violet-500" /> : null}
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
|
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
|
||||||
|
|
@ -3238,7 +3318,7 @@ export default function Opportunities() {
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{exportFilterOpen ? (
|
{exportFilterOpen ? (
|
||||||
<OpportunityExportFilterModal
|
<OpportunityExportFilterModal
|
||||||
initialFilters={applyDefaultOpportunityExportStageFilters(exportFilters, stageOptions)}
|
initialFilters={applyDefaultOpportunityExportStageFilters(exportFilters, stageOptions, archiveTab)}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
exportError={exportError}
|
exportError={exportError}
|
||||||
archiveTab={archiveTab}
|
archiveTab={archiveTab}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue