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

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.CreateOpportunityRequest;
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.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
@ -45,6 +46,13 @@ public class OpportunityController {
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")
public ApiResponse<List<OmsPreSalesOptionDTO>> getOmsPreSalesOptions(@RequestHeader("X-User-Id") Long userId) {
return ApiResponse.success(opportunityService.getOmsPreSalesOptions(CurrentUserUtils.requireCurrentUserId(userId)));

View File

@ -28,8 +28,12 @@ public class OpportunityItemDTO {
private String source;
private Long salesExpansionId;
private String salesExpansionName;
private String salesExpansionIntent;
private Boolean salesExpansionActive;
private Long channelExpansionId;
private String channelExpansionName;
private String channelExpansionIntent;
private String channelExpansionEstablishedDate;
private Long preSalesId;
private String preSalesName;
private String competitorName;
@ -214,6 +218,22 @@ public class OpportunityItemDTO {
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() {
return channelExpansionId;
}
@ -230,6 +250,22 @@ public class OpportunityItemDTO {
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() {
return preSalesId;
}

View File

@ -36,6 +36,11 @@ public interface OpportunityMapper {
@Param("keyword") String keyword,
@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")
List<OpportunityFollowUpDTO> selectOpportunityFollowUps(
@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.CreateOpportunityRequest;
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.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
@ -15,6 +16,8 @@ public interface OpportunityService {
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
OpportunityItemDTO getDetail(Long userId, Long opportunityId);
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
Long createOpportunity(Long userId, CreateOpportunityRequest request);

View File

@ -91,6 +91,21 @@ public class OpportunityServiceImpl implements OpportunityService {
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
public List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId) {
if (userId == null || userId <= 0) {

View File

@ -90,8 +90,25 @@
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,
@ -177,6 +194,141 @@
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
</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
f.id,

View File

@ -381,8 +381,12 @@ export interface OpportunityItem {
source?: string;
salesExpansionId?: number;
salesExpansionName?: string;
salesExpansionIntent?: string;
salesExpansionActive?: boolean;
channelExpansionId?: number;
channelExpansionName?: string;
channelExpansionIntent?: string;
channelExpansionEstablishedDate?: string;
preSalesId?: number;
preSalesName?: 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);
}
export async function getOpportunityDetail(opportunityId: number) {
return request<OpportunityItem>(`/api/opportunities/${opportunityId}`, undefined, true);
}
export async function getOpportunityMeta() {
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
}

View File

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