新增已丢单页面以及导出功能的调整

main
kangwenjing 2026-08-21 13:53:29 +08:00
parent 9453229055
commit 80eb1a493f
9 changed files with 162 additions and 43 deletions

View File

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

View File

@ -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}")

View File

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

View File

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

View File

@ -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);
} }

View File

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

View File

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

View File

@ -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();

View File

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