商机详情显示关联信息的回显问题修复

main
kangwenjing 2026-04-24 09:54:42 +08:00
parent 53fba9d991
commit 3a11b86a62
8 changed files with 335 additions and 48 deletions

View File

@ -5,6 +5,7 @@ import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
@ -45,6 +46,13 @@ public class OpportunityController {
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage)); return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage));
} }
@GetMapping("/{opportunityId}")
public ApiResponse<OpportunityItemDTO> getDetail(
@RequestHeader("X-User-Id") Long userId,
@PathVariable("opportunityId") Long opportunityId) {
return ApiResponse.success(opportunityService.getDetail(CurrentUserUtils.requireCurrentUserId(userId), opportunityId));
}
@GetMapping("/oms/pre-sales") @GetMapping("/oms/pre-sales")
public ApiResponse<List<OmsPreSalesOptionDTO>> getOmsPreSalesOptions(@RequestHeader("X-User-Id") Long userId) { public ApiResponse<List<OmsPreSalesOptionDTO>> getOmsPreSalesOptions(@RequestHeader("X-User-Id") Long userId) {
return ApiResponse.success(opportunityService.getOmsPreSalesOptions(CurrentUserUtils.requireCurrentUserId(userId))); return ApiResponse.success(opportunityService.getOmsPreSalesOptions(CurrentUserUtils.requireCurrentUserId(userId)));

View File

@ -28,8 +28,12 @@ public class OpportunityItemDTO {
private String source; private String source;
private Long salesExpansionId; private Long salesExpansionId;
private String salesExpansionName; private String salesExpansionName;
private String salesExpansionIntent;
private Boolean salesExpansionActive;
private Long channelExpansionId; private Long channelExpansionId;
private String channelExpansionName; private String channelExpansionName;
private String channelExpansionIntent;
private String channelExpansionEstablishedDate;
private Long preSalesId; private Long preSalesId;
private String preSalesName; private String preSalesName;
private String competitorName; private String competitorName;
@ -214,6 +218,22 @@ public class OpportunityItemDTO {
this.salesExpansionName = salesExpansionName; this.salesExpansionName = salesExpansionName;
} }
public String getSalesExpansionIntent() {
return salesExpansionIntent;
}
public void setSalesExpansionIntent(String salesExpansionIntent) {
this.salesExpansionIntent = salesExpansionIntent;
}
public Boolean getSalesExpansionActive() {
return salesExpansionActive;
}
public void setSalesExpansionActive(Boolean salesExpansionActive) {
this.salesExpansionActive = salesExpansionActive;
}
public Long getChannelExpansionId() { public Long getChannelExpansionId() {
return channelExpansionId; return channelExpansionId;
} }
@ -230,6 +250,22 @@ public class OpportunityItemDTO {
this.channelExpansionName = channelExpansionName; this.channelExpansionName = channelExpansionName;
} }
public String getChannelExpansionIntent() {
return channelExpansionIntent;
}
public void setChannelExpansionIntent(String channelExpansionIntent) {
this.channelExpansionIntent = channelExpansionIntent;
}
public String getChannelExpansionEstablishedDate() {
return channelExpansionEstablishedDate;
}
public void setChannelExpansionEstablishedDate(String channelExpansionEstablishedDate) {
this.channelExpansionEstablishedDate = channelExpansionEstablishedDate;
}
public Long getPreSalesId() { public Long getPreSalesId() {
return preSalesId; return preSalesId;
} }

View File

@ -36,6 +36,11 @@ public interface OpportunityMapper {
@Param("keyword") String keyword, @Param("keyword") String keyword,
@Param("stage") String stage); @Param("stage") String stage);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
OpportunityItemDTO selectOpportunityDetail(
@Param("userId") Long userId,
@Param("opportunityId") Long opportunityId);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id") @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<OpportunityFollowUpDTO> selectOpportunityFollowUps( List<OpportunityFollowUpDTO> selectOpportunityFollowUps(
@Param("userId") Long userId, @Param("userId") Long userId,

View File

@ -3,6 +3,7 @@ package com.unis.crm.service;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
@ -15,6 +16,8 @@ public interface OpportunityService {
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage); OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId); List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
Long createOpportunity(Long userId, CreateOpportunityRequest request); Long createOpportunity(Long userId, CreateOpportunityRequest request);

View File

@ -91,6 +91,21 @@ public class OpportunityServiceImpl implements OpportunityService {
return new OpportunityOverviewDTO(items); return new OpportunityOverviewDTO(items);
} }
@Override
public OpportunityItemDTO getDetail(Long userId, Long opportunityId) {
if (opportunityId == null || opportunityId <= 0) {
throw new BusinessException("商机不存在");
}
OpportunityItemDTO item = opportunityMapper.selectOpportunityDetail(userId, opportunityId);
if (item == null) {
throw new BusinessException("未找到商机详情");
}
attachFollowUps(userId, List.of(item));
return item;
}
@Override @Override
public List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId) { public List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId) {
if (userId == null || userId <= 0) { if (userId == null || userId <= 0) {

View File

@ -90,8 +90,25 @@
coalesce(o.source, '主动开发') as source, coalesce(o.source, '主动开发') as source,
o.sales_expansion_id as salesExpansionId, o.sales_expansion_id as salesExpansionId,
coalesce(se.candidate_name, '') as salesExpansionName, coalesce(se.candidate_name, '') as salesExpansionName,
case se.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else ''
end as salesExpansionIntent,
case
when o.sales_expansion_id is null then null
else (se.employment_status = 'active')
end as salesExpansionActive,
o.channel_expansion_id as channelExpansionId, o.channel_expansion_id as channelExpansionId,
coalesce(ce.channel_name, '') as channelExpansionName, coalesce(ce.channel_name, '') as channelExpansionName,
case ce.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else ''
end as channelExpansionIntent,
coalesce(to_char(ce.contact_established_date, 'YYYY-MM-DD'), '') as channelExpansionEstablishedDate,
o.pre_sales_id as preSalesId, o.pre_sales_id as preSalesId,
coalesce(o.pre_sales_name, '') as preSalesName, coalesce(o.pre_sales_name, '') as preSalesName,
coalesce(o.competitor_name, '') as competitorName, coalesce(o.competitor_name, '') as competitorName,
@ -177,6 +194,141 @@
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
</select> </select>
<select id="selectOpportunityDetail" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
select
o.id,
o.owner_user_id as ownerUserId,
o.opportunity_code as code,
o.opportunity_name as name,
coalesce(c.customer_name, '未填写最终客户') as client,
coalesce(u.display_name, '当前用户') as owner,
coalesce(
to_char(
case
when opportunity_followup.latest_followup_time is null then o.updated_at
else greatest(o.updated_at, opportunity_followup.latest_followup_time)
end,
'YYYY-MM-DD HH24:MI'
),
'无'
) as updatedAt,
coalesce(o.project_location, '') as projectLocation,
coalesce(operator_dict.item_value, o.operator_name, '') as operatorCode,
coalesce(operator_dict.item_label, nullif(o.operator_name, ''), '') as operatorName,
o.amount,
to_char(o.expected_close_date, 'YYYY-MM-DD') as date,
o.confidence_pct as confidence,
coalesce(stage_dict.item_value, o.stage, '') as stageCode,
coalesce(
stage_dict.item_label,
case coalesce(o.stage, 'initial_contact')
when 'initial_contact' then '初步沟通'
when 'solution_discussion' then '方案交流'
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
else coalesce(o.stage, '初步沟通')
end
) as stage,
coalesce(o.opportunity_type, '新建') as type,
coalesce(o.archived, false) as archived,
coalesce(o.pushed_to_oms, false) as pushedToOms,
coalesce(o.product_type, 'VDI云桌面') as product,
coalesce(o.source, '主动开发') as source,
o.sales_expansion_id as salesExpansionId,
coalesce(se.candidate_name, '') as salesExpansionName,
case se.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else ''
end as salesExpansionIntent,
case
when o.sales_expansion_id is null then null
else (se.employment_status = 'active')
end as salesExpansionActive,
o.channel_expansion_id as channelExpansionId,
coalesce(ce.channel_name, '') as channelExpansionName,
case ce.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else ''
end as channelExpansionIntent,
coalesce(to_char(ce.contact_established_date, 'YYYY-MM-DD'), '') as channelExpansionEstablishedDate,
o.pre_sales_id as preSalesId,
coalesce(o.pre_sales_name, '') as preSalesName,
coalesce(o.competitor_name, '') as competitorName,
coalesce((
select
case
when f.content like '项目最新进展:%' then
split_part(split_part(f.content, E'\n', 1), '项目最新进展:', 2)
else f.content
end
from crm_opportunity_followup f
where f.opportunity_id = o.id
and coalesce(nullif(btrim(f.content), ''), '') &lt;&gt; ''
order by f.followup_time desc, f.id desc
limit 1
), '') as latestProgress,
coalesce((
select
case
when coalesce(nullif(btrim(f.next_action), ''), '') &lt;&gt; '' then f.next_action
when f.content like '%后续规划:%' then
split_part(split_part(f.content, '后续规划:', 2), E'\n', 1)
else ''
end
from crm_opportunity_followup f
where f.opportunity_id = o.id
and (
coalesce(nullif(btrim(f.next_action), ''), '') &lt;&gt; ''
or f.content like '%后续规划:%'
)
order by f.followup_time desc, f.id desc
limit 1
), '') as nextPlan,
coalesce(o.description, '') as notes
from crm_opportunity o
left join (
select
f.opportunity_id,
max(f.followup_time) as latest_followup_time
from crm_opportunity_followup f
group by f.opportunity_id
) opportunity_followup on opportunity_followup.opportunity_id = o.id
left join crm_customer c on c.id = o.customer_id
left join sys_user u on u.user_id = o.owner_user_id
left join crm_sales_expansion se on se.id = o.sales_expansion_id
left join crm_channel_expansion ce on ce.id = o.channel_expansion_id
left join sys_dict_item stage_dict
on stage_dict.type_code = 'sj_xmjd'
and (
stage_dict.item_value = o.stage
or stage_dict.item_label = case coalesce(o.stage, 'initial_contact')
when 'initial_contact' then '初步沟通'
when 'solution_discussion' then '方案交流'
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
else o.stage
end
)
and stage_dict.status = 1
and coalesce(stage_dict.is_deleted, 0) = 0
left join sys_dict_item operator_dict
on operator_dict.type_code = 'sj_yzf'
and operator_dict.item_value = o.operator_name
and operator_dict.status = 1
and coalesce(operator_dict.is_deleted, 0) = 0
where 1 = 1
and o.id = #{opportunityId}
limit 1
</select>
<select id="selectOpportunityFollowUps" resultType="com.unis.crm.dto.opportunity.OpportunityFollowUpDTO"> <select id="selectOpportunityFollowUps" resultType="com.unis.crm.dto.opportunity.OpportunityFollowUpDTO">
select select
f.id, f.id,

View File

@ -381,8 +381,12 @@ export interface OpportunityItem {
source?: string; source?: string;
salesExpansionId?: number; salesExpansionId?: number;
salesExpansionName?: string; salesExpansionName?: string;
salesExpansionIntent?: string;
salesExpansionActive?: boolean;
channelExpansionId?: number; channelExpansionId?: number;
channelExpansionName?: string; channelExpansionName?: string;
channelExpansionIntent?: string;
channelExpansionEstablishedDate?: string;
preSalesId?: number; preSalesId?: number;
preSalesName?: string; preSalesName?: string;
competitorName?: string; competitorName?: string;
@ -1102,6 +1106,10 @@ export async function getOpportunityOverview(keyword?: string, stage?: string) {
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true); return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
} }
export async function getOpportunityDetail(opportunityId: number) {
return request<OpportunityItem>(`/api/opportunities/${opportunityId}`, undefined, true);
}
export async function getOpportunityMeta() { export async function getOpportunityMeta() {
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true); return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
} }

View File

@ -11,6 +11,7 @@ import {
createSalesExpansion, createSalesExpansion,
getExpansionCityOptions, getExpansionCityOptions,
getExpansionMeta, getExpansionMeta,
getOpportunityDetail,
getOpportunityMeta, getOpportunityMeta,
getOpportunityExpansionOptions, getOpportunityExpansionOptions,
getOpportunityOmsPreSalesOptions, getOpportunityOmsPreSalesOptions,
@ -1451,6 +1452,9 @@ export default function Opportunities() {
const [stageFilterOpen, setStageFilterOpen] = useState(false); const [stageFilterOpen, setStageFilterOpen] = useState(false);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null); const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
const [selectedItemDetail, setSelectedItemDetail] = useState<OpportunityItem | null>(null);
const [loadingSelectedItemDetail, setLoadingSelectedItemDetail] = useState(false);
const [selectedItemDetailError, setSelectedItemDetailError] = useState("");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [pushConfirmOpen, setPushConfirmOpen] = useState(false); const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
@ -1553,6 +1557,42 @@ export default function Opportunities() {
} }
}, [location.state, items]); }, [location.state, items]);
useEffect(() => {
if (!selectedItem?.id) {
setSelectedItemDetail(null);
setSelectedItemDetailError("");
setLoadingSelectedItemDetail(false);
return;
}
let cancelled = false;
setLoadingSelectedItemDetail(true);
setSelectedItemDetail(null);
setSelectedItemDetailError("");
void getOpportunityDetail(selectedItem.id)
.then((detail) => {
if (!cancelled) {
setSelectedItemDetail(detail);
}
})
.catch((detailError) => {
if (!cancelled) {
setSelectedItemDetail(null);
setSelectedItemDetailError(detailError instanceof Error ? detailError.message : "加载商机详情失败");
}
})
.finally(() => {
if (!cancelled) {
setLoadingSelectedItemDetail(false);
}
});
return () => {
cancelled = true;
};
}, [selectedItem?.id]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -1784,7 +1824,8 @@ export default function Opportunities() {
setQuickCreateOpen(true); setQuickCreateOpen(true);
}; };
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? []; const detailItem = selectedItem ? (selectedItemDetail ?? selectedItem) : null;
const followUpRecords: OpportunityFollowUp[] = detailItem?.followUps ?? [];
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))); const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const stageFilterOptions = [ const stageFilterOptions = [
{ label: "全部", value: "全部" }, { label: "全部", value: "全部" },
@ -1827,21 +1868,31 @@ export default function Opportunities() {
), ),
]; ];
const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部"; const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
const selectedSalesExpansion = selectedItem?.salesExpansionId const selectedSalesExpansion = detailItem?.salesExpansionId
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null ? salesExpansionOptions.find((item) => item.id === detailItem.salesExpansionId) ?? null
: null; : null;
const selectedChannelExpansion = selectedItem?.channelExpansionId const selectedChannelExpansion = detailItem?.channelExpansionId
? channelExpansionOptions.find((item) => item.id === selectedItem.channelExpansionId) ?? null ? channelExpansionOptions.find((item) => item.id === detailItem.channelExpansionId) ?? null
: null; : null;
const selectedSalesExpansionName = formatSalesExpansionOptionLabel(selectedSalesExpansion) const selectedSalesExpansionName = formatSalesExpansionOptionLabel(selectedSalesExpansion)
|| selectedItem?.salesExpansionName || detailItem?.salesExpansionName
|| "";
const selectedSalesExpansionIntent = detailItem?.salesExpansionIntent
|| selectedSalesExpansion?.intent
|| "";
const selectedSalesExpansionActive = detailItem?.salesExpansionActive ?? selectedSalesExpansion?.active;
const selectedChannelExpansionName = detailItem?.channelExpansionName || selectedChannelExpansion?.name || "";
const selectedChannelExpansionIntent = detailItem?.channelExpansionIntent
|| selectedChannelExpansion?.intent
|| "";
const selectedChannelExpansionEstablishedDate = detailItem?.channelExpansionEstablishedDate
|| selectedChannelExpansion?.establishedDate
|| ""; || "";
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
const hasSelectedSalesExpansion = Boolean(selectedSalesExpansionName); const hasSelectedSalesExpansion = Boolean(selectedSalesExpansionName);
const hasSelectedChannelExpansion = Boolean(selectedChannelExpansionName); const hasSelectedChannelExpansion = Boolean(selectedChannelExpansionName);
const selectedPreSalesName = selectedItem?.preSalesName || "无"; const selectedPreSalesName = detailItem?.preSalesName || "无";
const isSelectedItemOwnedByCurrentUser = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId); const isSelectedItemOwnedByCurrentUser = Boolean(detailItem && currentUserId !== undefined && detailItem.ownerUserId === currentUserId);
const isSelectedItemArchived = Boolean(selectedItem?.archived); const isSelectedItemArchived = Boolean(detailItem?.archived);
const canEditSelectedItem = isSelectedItemOwnedByCurrentUser && !isSelectedItemArchived; const canEditSelectedItem = isSelectedItemOwnedByCurrentUser && !isSelectedItemArchived;
const canPushSelectedItem = isSelectedItemOwnedByCurrentUser && !isSelectedItemArchived; const canPushSelectedItem = isSelectedItemOwnedByCurrentUser && !isSelectedItemArchived;
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions); const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
@ -1961,6 +2012,7 @@ export default function Opportunities() {
const relatedChannel = item.channelExpansionId const relatedChannel = item.channelExpansionId
? channelExpansionOptions.find((option) => option.id === item.channelExpansionId) ?? null ? channelExpansionOptions.find((option) => option.id === item.channelExpansionId) ?? null
: null; : null;
const relatedSalesActive = item.salesExpansionActive ?? relatedSales?.active;
const followUpText = (item.followUps ?? []) const followUpText = (item.followUps ?? [])
.map((record) => { .map((record) => {
const summary = getOpportunityFollowUpSummary(record); const summary = getOpportunityFollowUpSummary(record);
@ -1985,11 +2037,11 @@ export default function Opportunities() {
item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`, item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`,
normalizeOpportunityExportText(item.date), normalizeOpportunityExportText(item.date),
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name), normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
normalizeOpportunityExportText(relatedSales?.intent), normalizeOpportunityExportText(item.salesExpansionIntent || relatedSales?.intent),
relatedSales ? (relatedSales.active ? "是" : "否") : "", relatedSalesActive === undefined ? "" : relatedSalesActive ? "是" : "否",
normalizeOpportunityExportText(item.channelExpansionName || relatedChannel?.name), normalizeOpportunityExportText(item.channelExpansionName || relatedChannel?.name),
normalizeOpportunityExportText(relatedChannel?.intent), normalizeOpportunityExportText(item.channelExpansionIntent || relatedChannel?.intent),
normalizeOpportunityExportText(relatedChannel?.establishedDate), normalizeOpportunityExportText(item.channelExpansionEstablishedDate || relatedChannel?.establishedDate),
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name), normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
normalizeOpportunityExportText(item.preSalesName), normalizeOpportunityExportText(item.preSalesName),
normalizeOpportunityExportText(item.competitorName), normalizeOpportunityExportText(item.competitorName),
@ -2272,6 +2324,7 @@ export default function Opportunities() {
if (!selectedItem) { if (!selectedItem) {
return; return;
} }
const sourceItem = detailItem ?? selectedItem;
if (!isSelectedItemOwnedByCurrentUser) { if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可编辑本人负责的商机"); setError("仅可编辑本人负责的商机");
return; return;
@ -2282,8 +2335,8 @@ export default function Opportunities() {
} }
setError(""); setError("");
setFieldErrors({}); setFieldErrors({});
setForm(toFormFromItem(selectedItem, effectiveConfidenceOptions)); setForm(toFormFromItem(sourceItem, effectiveConfidenceOptions));
const competitorState = parseCompetitorState(selectedItem.competitorName); const competitorState = parseCompetitorState(sourceItem.competitorName);
setSelectedCompetitors(competitorState.selections); setSelectedCompetitors(competitorState.selections);
setCustomCompetitorName(competitorState.customName); setCustomCompetitorName(competitorState.customName);
setEditOpen(true); setEditOpen(true);
@ -2385,6 +2438,7 @@ export default function Opportunities() {
if (!selectedItem || pushingOms) { if (!selectedItem || pushingOms) {
return; return;
} }
const sourceItem = detailItem ?? selectedItem;
if (!isSelectedItemOwnedByCurrentUser) { if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可推送本人负责的商机"); setError("仅可推送本人负责的商机");
return; return;
@ -2395,14 +2449,14 @@ export default function Opportunities() {
} }
setError(""); setError("");
syncPushPreSalesSelection(selectedItem, omsPreSalesOptions); syncPushPreSalesSelection(sourceItem, omsPreSalesOptions);
setPushConfirmOpen(true); setPushConfirmOpen(true);
setLoadingOmsPreSales(true); setLoadingOmsPreSales(true);
try { try {
const data = await getOpportunityOmsPreSalesOptions(); const data = await getOpportunityOmsPreSalesOptions();
setOmsPreSalesOptions(data); setOmsPreSalesOptions(data);
syncPushPreSalesSelection(selectedItem, data); syncPushPreSalesSelection(sourceItem, data);
} catch (loadError) { } catch (loadError) {
setOmsPreSalesOptions([]); setOmsPreSalesOptions([]);
setError(loadError instanceof Error ? loadError.message : "加载售前人员失败"); setError(loadError instanceof Error ? loadError.message : "加载售前人员失败");
@ -2874,14 +2928,14 @@ export default function Opportunities() {
/> />
{fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null} {fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null}
</label> </label>
{selectedItem ? ( {detailItem ? (
<> <>
<label className="space-y-2 sm:col-span-2"> <label className="space-y-2 sm:col-span-2">
<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>
<textarea <textarea
rows={3} rows={3}
readOnly readOnly
value={selectedItem.latestProgress || "暂无日报回写进展"} value={detailItem.latestProgress || "暂无日报回写进展"}
className="w-full cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-800/70 dark:text-slate-400" className="w-full cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-800/70 dark:text-slate-400"
/> />
<p className="text-xs text-slate-400 dark:text-slate-500"></p> <p className="text-xs text-slate-400 dark:text-slate-500"></p>
@ -2891,7 +2945,7 @@ export default function Opportunities() {
<textarea <textarea
rows={3} rows={3}
readOnly readOnly
value={selectedItem.nextPlan || "暂无日报回写规划"} value={detailItem.nextPlan || "暂无日报回写规划"}
className="w-full cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-800/70 dark:text-slate-400" className="w-full cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-800/70 dark:text-slate-400"
/> />
<p className="text-xs text-slate-400 dark:text-slate-500"></p> <p className="text-xs text-slate-400 dark:text-slate-500"></p>
@ -2960,7 +3014,7 @@ export default function Opportunities() {
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{pushConfirmOpen && selectedItem ? ( {pushConfirmOpen && detailItem ? (
<> <>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -2993,7 +3047,7 @@ export default function Opportunities() {
<div className="px-5 py-4 text-sm text-slate-600 dark:text-slate-300 sm:px-6"> <div className="px-5 py-4 text-sm text-slate-600 dark:text-slate-300 sm:px-6">
<div className="crm-form-section"> <div className="crm-form-section">
<p className="text-xs text-slate-400 dark:text-slate-500"></p> <p className="text-xs text-slate-400 dark:text-slate-500"></p>
<p className="mt-1 font-medium text-slate-900 dark:text-white">{selectedItem.name || selectedItem.code || `#${selectedItem.id}`}</p> <p className="mt-1 font-medium text-slate-900 dark:text-white">{detailItem.name || detailItem.code || `#${detailItem.id}`}</p>
</div> </div>
<div className="crm-form-section mt-4"> <div className="crm-form-section mt-4">
<p className="mb-2 text-xs text-slate-400 dark:text-slate-500"></p> <p className="mb-2 text-xs text-slate-400 dark:text-slate-500"></p>
@ -3040,7 +3094,7 @@ export default function Opportunities() {
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{selectedItem && ( {detailItem && (
<> <>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -3070,12 +3124,12 @@ export default function Opportunities() {
<div className="crm-modal-stack"> <div className="crm-modal-stack">
<div> <div>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">{selectedItem.code || `#${selectedItem.id}`}</span> <span className="text-xs font-medium text-slate-400 dark:text-slate-500">{detailItem.code || `#${detailItem.id}`}</span>
</div> </div>
<h3 className="line-clamp-1 text-lg font-bold leading-tight text-slate-900 dark:text-white sm:break-anywhere sm:line-clamp-none sm:text-xl">{selectedItem.name || "未命名商机"}</h3> <h3 className="line-clamp-1 text-lg font-bold leading-tight text-slate-900 dark:text-white sm:break-anywhere sm:line-clamp-none sm:text-xl">{detailItem.name || "未命名商机"}</h3>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<span className="crm-pill crm-pill-neutral">{selectedItem.stage || "初步沟通"}</span> <span className="crm-pill crm-pill-neutral">{detailItem.stage || "初步沟通"}</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence)}`}> {getConfidenceLabel(selectedItem.confidence, effectiveConfidenceOptions)}</span> <span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(detailItem.confidence)}`}> {getConfidenceLabel(detailItem.confidence, effectiveConfidenceOptions)}</span>
</div> </div>
</div> </div>
@ -3084,10 +3138,16 @@ export default function Opportunities() {
<FileText className="crm-icon-md text-violet-500" /> <FileText className="crm-icon-md text-violet-500" />
</h4> </h4>
{loadingSelectedItemDetail ? (
<p className="crm-field-note">...</p>
) : null}
{selectedItemDetailError ? (
<div className="crm-alert crm-alert-error">{selectedItemDetailError}</div>
) : null}
<div className="crm-detail-grid text-sm md:grid-cols-2"> <div className="crm-detail-grid text-sm md:grid-cols-2">
<DetailItem label="项目地" value={selectedItem.projectLocation || "无"} /> <DetailItem label="项目地" value={detailItem.projectLocation || "无"} />
<DetailItem label="最终用户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} /> <DetailItem label="最终用户" value={detailItem.client || "无"} icon={<Building className="h-3 w-3" />} />
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} /> <DetailItem label="运作方" value={detailItem.operatorName || "无"} />
{hasSelectedChannelExpansion ? ( {hasSelectedChannelExpansion ? (
<DetailItem label="渠道名称" value={selectedChannelExpansionName} icon={<Building className="h-3 w-3" />} /> <DetailItem label="渠道名称" value={selectedChannelExpansionName} icon={<Building className="h-3 w-3" />} />
) : null} ) : null}
@ -3095,30 +3155,30 @@ export default function Opportunities() {
<DetailItem label="新华三负责人" value={selectedSalesExpansionName} icon={<User className="h-3 w-3" />} /> <DetailItem label="新华三负责人" value={selectedSalesExpansionName} icon={<User className="h-3 w-3" />} />
) : null} ) : null}
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} /> <DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} /> <DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(detailItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} /> <DetailItem label="预计下单时间" value={detailItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence, effectiveConfidenceOptions)} /> <DetailItem label="项目把握度" value={getConfidenceLabel(detailItem.confidence, effectiveConfidenceOptions)} />
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} /> <DetailItem label="项目阶段" value={detailItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
<DetailItem <DetailItem
label="是否已推送OMS" label="是否已推送OMS"
value={( value={(
<span <span
className={cn( className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold", "inline-flex rounded-full px-2.5 py-1 text-xs font-semibold",
selectedItem.pushedToOms detailItem.pushedToOms
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300" ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", : "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
)} )}
> >
{selectedItem.pushedToOms ? "已推送" : "未推送"} {detailItem.pushedToOms ? "已推送" : "未推送"}
</span> </span>
)} )}
/> />
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} /> <DetailItem label="竞争对手" value={detailItem.competitorName || "无"} />
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} /> <DetailItem label="建设类型" value={detailItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" /> <DetailItem label="项目最新进展" value={detailItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
<DetailItem label="后续规划" value={selectedItem.nextPlan || "暂无日报回写规划"} className="md:col-span-2" /> <DetailItem label="后续规划" value={detailItem.nextPlan || "暂无日报回写规划"} className="md:col-span-2" />
<DetailItem label="备注说明" value={selectedItem.notes || "无"} className="md:col-span-2" /> <DetailItem label="备注说明" value={detailItem.notes || "无"} className="md:col-span-2" />
</div> </div>
</div> </div>
@ -3163,8 +3223,8 @@ export default function Opportunities() {
selectedSalesExpansion || selectedSalesExpansionName ? ( selectedSalesExpansion || selectedSalesExpansionName ? (
<div className="crm-detail-grid text-sm sm:grid-cols-3"> <div className="crm-detail-grid text-sm sm:grid-cols-3">
<DetailItem label="姓名" value={selectedSalesExpansion?.name || selectedSalesExpansionName || "无"} /> <DetailItem label="姓名" value={selectedSalesExpansion?.name || selectedSalesExpansionName || "无"} />
<DetailItem label="合作意向" value={selectedSalesExpansion?.intent || "无"} /> <DetailItem label="合作意向" value={selectedSalesExpansionIntent || "无"} />
<DetailItem label="是否在职" value={selectedSalesExpansion ? (selectedSalesExpansion.active ? "是" : "否") : "待同步"} /> <DetailItem label="是否在职" value={selectedSalesExpansionActive === undefined ? "待同步" : selectedSalesExpansionActive ? "是" : "否"} />
</div> </div>
) : ( ) : (
<div className="crm-empty-panel"> <div className="crm-empty-panel">
@ -3177,8 +3237,8 @@ export default function Opportunities() {
selectedChannelExpansion || selectedChannelExpansionName ? ( selectedChannelExpansion || selectedChannelExpansionName ? (
<div className="crm-detail-grid text-sm sm:grid-cols-3"> <div className="crm-detail-grid text-sm sm:grid-cols-3">
<DetailItem label="渠道名称" value={selectedChannelExpansion?.name || selectedChannelExpansionName || "无"} /> <DetailItem label="渠道名称" value={selectedChannelExpansion?.name || selectedChannelExpansionName || "无"} />
<DetailItem label="合作意向" value={selectedChannelExpansion?.intent || "无"} /> <DetailItem label="合作意向" value={selectedChannelExpansionIntent || "无"} />
<DetailItem label="建立联系时间" value={selectedChannelExpansion?.establishedDate || "无"} /> <DetailItem label="建立联系时间" value={selectedChannelExpansionEstablishedDate || "无"} />
</div> </div>
) : ( ) : (
<div className="crm-empty-panel"> <div className="crm-empty-panel">
@ -3253,7 +3313,7 @@ export default function Opportunities() {
? !isSelectedItemOwnedByCurrentUser ? !isSelectedItemOwnedByCurrentUser
? "仅本人可操作" ? "仅本人可操作"
: "已签单不可推送" : "已签单不可推送"
: selectedItem.pushedToOms : detailItem.pushedToOms
? "重新推送 OMS" ? "重新推送 OMS"
: "推送 OMS" : "推送 OMS"
} }
@ -3270,7 +3330,7 @@ export default function Opportunities() {
: "已签单不可推送" : "已签单不可推送"
: pushingOms : pushingOms
? "推送中..." ? "推送中..."
: selectedItem.pushedToOms : detailItem.pushedToOms
? "重新推送 OMS" ? "重新推送 OMS"
: "推送 OMS"} : "推送 OMS"}
</button> </button>