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