import { useEffect, useRef, useState, type ReactNode } from "react"; import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth"; import { AdaptiveSelect } from "@/components/AdaptiveSelect"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import { cn } from "@/lib/utils"; const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold"; const CONFIDENCE_OPTIONS = [ { value: "A", label: "A" }, { value: "B", label: "B" }, { value: "C", label: "C" }, ] as const; const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [ { value: "新建", label: "新建" }, { value: "扩容", label: "扩容" }, { value: "替换", label: "替换" }, ] as const; type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"]; const COMPETITOR_OPTIONS = [ "深信服", "锐捷", "华为", "中兴", "噢易云", "无", "其他", ] as const; type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number]; type OperatorMode = "none" | "h3c" | "channel" | "both"; type OpportunityArchiveTab = "active" | "archived"; type OpportunityField = | "projectLocation" | "opportunityName" | "customerName" | "operatorName" | "salesExpansionId" | "channelExpansionId" | "amount" | "expectedCloseDate" | "confidencePct" | "stage" | "competitorName" | "opportunityType"; const defaultForm: CreateOpportunityPayload = { opportunityName: "", customerName: "", projectLocation: "", operatorName: "", amount: 0, expectedCloseDate: "", confidencePct: "C", stage: "", opportunityType: "", productType: "VDI云桌面", source: "主动开发", salesExpansionId: undefined, channelExpansionId: undefined, competitorName: "", description: "", }; function formatAmount(value?: number) { if (value === undefined || value === null || Number.isNaN(Number(value))) { return "0"; } return new Intl.NumberFormat("zh-CN").format(Number(value)); } function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload { return { opportunityName: item.name || "", customerName: item.client || "", projectLocation: item.projectLocation || "", amount: item.amount || 0, expectedCloseDate: item.date || "", confidencePct: normalizeConfidenceGrade(item.confidence), stage: item.stageCode || item.stage || "", opportunityType: item.type || "", productType: item.product || "VDI云桌面", source: item.source || "主动开发", salesExpansionId: item.salesExpansionId, channelExpansionId: item.channelExpansionId, operatorName: item.operatorCode || item.operatorName || "", competitorName: item.competitorName || "", description: item.notes || "", }; } function normalizeConfidenceGrade(value?: string | number | null): ConfidenceGrade { if (value === null || value === undefined) { return "C"; } if (typeof value === "number") { if (value >= 80) return "A"; if (value >= 60) return "B"; return "C"; } const normalized = value.trim().toUpperCase(); if (normalized === "A" || normalized === "B" || normalized === "C") { return normalized; } if (normalized === "80") { return "A"; } if (normalized === "60") { return "B"; } return "C"; } function getConfidenceOptionValue(score?: string | number | null) { return normalizeConfidenceGrade(score); } function getConfidenceLabel(score?: string | number | null) { const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === normalizeConfidenceGrade(score)); return matchedOption?.label || "C"; } function getConfidenceBadgeClass(score?: string | number | null) { const normalizedGrade = normalizeConfidenceGrade(score); if (normalizedGrade === "A") return "crm-pill crm-pill-emerald"; if (normalizedGrade === "B") return "crm-pill crm-pill-amber"; return "crm-pill crm-pill-rose"; } function normalizeCompetitorSelections(selected: CompetitorOption[]) { const deduped = Array.from(new Set(selected)); if (deduped.includes("无")) { return ["无"] as CompetitorOption[]; } return deduped; } function parseCompetitorState(value?: string) { const raw = value?.trim() || ""; if (!raw) { return { selections: [] as CompetitorOption[], customName: "", }; } const tokens = raw .split(/[,,、;;\n]+/) .map((item) => item.trim()) .filter(Boolean); const selections: CompetitorOption[] = []; const customValues: string[] = []; tokens.forEach((token) => { if ((COMPETITOR_OPTIONS as readonly string[]).includes(token) && token !== "其他") { selections.push(token as CompetitorOption); return; } customValues.push(token); }); if (customValues.length > 0) { selections.push("其他"); } return { selections: normalizeCompetitorSelections(selections), customName: customValues.join("、"), }; } function buildCompetitorValue(selected: CompetitorOption[], customName?: string) { const normalizedSelections = normalizeCompetitorSelections(selected); if (!normalizedSelections.length) { return undefined; } if (normalizedSelections.includes("无")) { return "无"; } const values = normalizedSelections .filter((item) => item !== "其他") .map((item) => item.trim()); if (normalizedSelections.includes("其他")) { const trimmedCustomName = customName?.trim(); if (trimmedCustomName) { values.push(trimmedCustomName); } } const deduped = Array.from(new Set(values.filter(Boolean))); return deduped.length ? deduped.join("、") : undefined; } function normalizeOperatorToken(value?: string) { return (value || "") .trim() .toLowerCase() .replace(/\s+/g, "") .replace(/+/g, "+"); } function resolveOperatorMode(operatorValue: string | undefined, operatorOptions: OpportunityDictOption[]): OperatorMode { const selectedOption = operatorOptions.find((item) => (item.value || "") === (operatorValue || "")); const candidates = [selectedOption?.label, selectedOption?.value, operatorValue] .filter(Boolean) .map((item) => normalizeOperatorToken(item)); if (!candidates.length || candidates.every((item) => !item)) { return "none"; } const hasH3c = candidates.some((item) => item.includes("新华三") || item.includes("h3c")); const hasChannel = candidates.some((item) => item.includes("渠道") || item.includes("channel")); if (hasH3c && hasChannel) { return "both"; } if (hasH3c) { return "h3c"; } if (hasChannel) { return "channel"; } return "none"; } function buildOpportunitySubmitPayload( form: CreateOpportunityPayload, selectedCompetitors: CompetitorOption[], customCompetitorName: string, operatorMode: OperatorMode, ): CreateOpportunityPayload { return { ...form, salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined, channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined, competitorName: buildCompetitorValue(selectedCompetitors, customCompetitorName), }; } function validateOperatorRelations( operatorMode: OperatorMode, salesExpansionId: number | undefined, channelExpansionId: number | undefined, ) { if (operatorMode === "h3c" && !salesExpansionId) { throw new Error("运作方选择“新华三”时,新华三负责人必须填写"); } if (operatorMode === "channel" && !channelExpansionId) { throw new Error("运作方选择“渠道”时,渠道名称必须填写"); } if (operatorMode === "both" && !salesExpansionId && !channelExpansionId) { throw new Error("运作方选择“新华三+渠道”时,新华三负责人和渠道名称都必须填写"); } if (operatorMode === "both" && !salesExpansionId) { throw new Error("运作方选择“新华三+渠道”时,新华三负责人必须填写"); } if (operatorMode === "both" && !channelExpansionId) { throw new Error("运作方选择“新华三+渠道”时,渠道名称必须填写"); } } function getFieldInputClass(hasError: boolean) { return cn( "crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", hasError ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800", ); } function RequiredMark() { return *; } function validateOpportunityForm( form: CreateOpportunityPayload, selectedCompetitors: CompetitorOption[], customCompetitorName: string, operatorMode: OperatorMode, ) { const errors: Partial> = {}; if (!form.projectLocation?.trim()) { errors.projectLocation = "请选择项目地"; } if (!form.opportunityName?.trim()) { errors.opportunityName = "请填写项目名称"; } if (!form.customerName?.trim()) { errors.customerName = "请填写最终客户"; } if (!form.operatorName?.trim()) { errors.operatorName = "请选择运作方"; } if (!form.amount || form.amount <= 0) { errors.amount = "请填写预计金额"; } if (!form.expectedCloseDate?.trim()) { errors.expectedCloseDate = "请选择预计下单时间"; } if (!form.confidencePct?.trim()) { errors.confidencePct = "请选择项目把握度"; } if (!form.stage?.trim()) { errors.stage = "请选择项目阶段"; } if (!form.opportunityType?.trim()) { errors.opportunityType = "请选择建设类型"; } if (selectedCompetitors.length === 0) { errors.competitorName = "请至少选择一个竞争对手"; } else if (selectedCompetitors.includes("其他") && !customCompetitorName.trim()) { errors.competitorName = "已选择“其他”,请填写其他竞争对手"; } if (operatorMode === "h3c" && !form.salesExpansionId) { errors.salesExpansionId = "请选择新华三负责人"; } if (operatorMode === "channel" && !form.channelExpansionId) { errors.channelExpansionId = "请选择渠道名称"; } if (operatorMode === "both" && !form.salesExpansionId) { errors.salesExpansionId = "请选择新华三负责人"; } if (operatorMode === "both" && !form.channelExpansionId) { errors.channelExpansionId = "请选择渠道名称"; } return errors; } function ModalShell({ title, subtitle, onClose, children, footer, }: { title: string; subtitle: string; onClose: () => void; children: ReactNode; footer: ReactNode; }) { const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; return ( <>

{title}

{subtitle}

{children}
{footer}
); } function DetailItem({ label, value, icon, className = "", }: { label: string; value: ReactNode; icon?: ReactNode; className?: string; }) { return (

{icon} {label}

{value}
); } type SearchableOption = { value: number | string; label: string; keywords?: string[]; }; function dedupeSearchableOptions(options: SearchableOption[]) { const seenValues = new Set(); return options.filter((option) => { if (seenValues.has(option.value)) { return false; } seenValues.add(option.value); return true; }); } function useIsMobileViewport() { const [isMobile, setIsMobile] = useState(() => { if (typeof window === "undefined") { return false; } return window.matchMedia("(max-width: 639px)").matches; }); useEffect(() => { if (typeof window === "undefined") { return; } const mediaQuery = window.matchMedia("(max-width: 639px)"); const handleChange = () => setIsMobile(mediaQuery.matches); handleChange(); if (typeof mediaQuery.addEventListener === "function") { mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); } mediaQuery.addListener(handleChange); return () => mediaQuery.removeListener(handleChange); }, []); return isMobile; } function SearchableSelect({ value, options, placeholder, searchPlaceholder, emptyText, className, onChange, }: { value?: number; options: SearchableOption[]; placeholder: string; searchPlaceholder: string; emptyText: string; className?: string; onChange: (value?: number) => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const containerRef = useRef(null); const isMobile = useIsMobileViewport(); const normalizedOptions = dedupeSearchableOptions(options); const selectedOption = normalizedOptions.find((item) => item.value === value); const normalizedQuery = query.trim().toLowerCase(); const filteredOptions = normalizedOptions.filter((item) => { if (!normalizedQuery) { return true; } const haystacks = [item.label, ...(item.keywords ?? [])] .filter(Boolean) .map((entry) => entry.toLowerCase()); return haystacks.some((entry) => entry.includes(normalizedQuery)); }); useEffect(() => { if (!open || isMobile) { return; } const handlePointerDown = (event: MouseEvent) => { if (!containerRef.current?.contains(event.target as Node)) { setOpen(false); setQuery(""); } }; document.addEventListener("mousedown", handlePointerDown); return () => { document.removeEventListener("mousedown", handlePointerDown); }; }, [isMobile, open]); useEffect(() => { if (!open || !isMobile) { return; } const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = previousOverflow; }; }, [isMobile, open]); const renderSearchBody = () => ( <>
setQuery(event.target.value)} placeholder={searchPlaceholder} className="crm-input-text w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-800/60 dark:text-white" />
{filteredOptions.length > 0 ? ( filteredOptions.map((item) => ( )) ) : (
{emptyText}
)}
); return (
{open && !isMobile ? ( {renderSearchBody()} ) : null} {open && isMobile ? ( <> { setOpen(false); setQuery(""); }} />

{placeholder}

搜索并选择一个对象

{renderSearchBody()}
) : null}
); } function CompetitorMultiSelect({ value, options, placeholder, className, onChange, }: { value: CompetitorOption[]; options: readonly CompetitorOption[]; placeholder: string; className?: string; onChange: (value: CompetitorOption[]) => void; }) { const [open, setOpen] = useState(false); const containerRef = useRef(null); const isMobile = useIsMobileViewport(); const summary = value.length > 0 ? value.join("、") : placeholder; useEffect(() => { if (!open || isMobile) { return; } const handlePointerDown = (event: MouseEvent) => { if (!containerRef.current?.contains(event.target as Node)) { setOpen(false); } }; document.addEventListener("mousedown", handlePointerDown); return () => { document.removeEventListener("mousedown", handlePointerDown); }; }, [isMobile, open]); useEffect(() => { if (!open || !isMobile) { return; } const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = previousOverflow; }; }, [isMobile, open]); const toggleOption = (option: CompetitorOption) => { const exists = value.includes(option); if (option === "无") { onChange(exists ? [] : ["无"]); return; } const next = exists ? value.filter((item) => item !== option) : normalizeCompetitorSelections([...value.filter((item) => item !== "无"), option]); onChange(next); }; const renderOptions = () => ( <>
{options.map((option) => { const active = value.includes(option); return ( ); })}
); return (
{open && !isMobile ? (

竞争对手

支持多选,选择“其他”后可手动录入。

{renderOptions()}
) : null}
{open && isMobile ? ( <> setOpen(false)} />

竞争对手

支持多选,选择“其他”后可手动录入。

{renderOptions()}
) : null}
); } export default function Opportunities() { const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; const [archiveTab, setArchiveTab] = useState("active"); const [filter, setFilter] = useState("全部"); const [stageFilterOpen, setStageFilterOpen] = useState(false); const [keyword, setKeyword] = useState(""); const [selectedItem, setSelectedItem] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [pushConfirmOpen, setPushConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [pushingOms, setPushingOms] = useState(false); const [error, setError] = useState(""); const [items, setItems] = useState([]); const [salesExpansionOptions, setSalesExpansionOptions] = useState([]); const [channelExpansionOptions, setChannelExpansionOptions] = useState([]); const [omsPreSalesOptions, setOmsPreSalesOptions] = useState([]); const [stageOptions, setStageOptions] = useState([]); const [operatorOptions, setOperatorOptions] = useState([]); const [projectLocationOptions, setProjectLocationOptions] = useState([]); const [opportunityTypeOptions, setOpportunityTypeOptions] = useState([]); const [form, setForm] = useState(defaultForm); const [pushPreSalesId, setPushPreSalesId] = useState(undefined); const [pushPreSalesName, setPushPreSalesName] = useState(""); const [loadingOmsPreSales, setLoadingOmsPreSales] = useState(false); const [selectedCompetitors, setSelectedCompetitors] = useState([]); const [customCompetitorName, setCustomCompetitorName] = useState(""); const [fieldErrors, setFieldErrors] = useState>>({}); const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales"); const hasForegroundModal = createOpen || editOpen || pushConfirmOpen; useEffect(() => { let cancelled = false; async function load() { try { const data = await getOpportunityOverview(keyword, filter); if (!cancelled) { setItems(data.items ?? []); setSelectedItem(null); } } catch { if (!cancelled) { setItems([]); setSelectedItem(null); } } } void load(); return () => { cancelled = true; }; }, [keyword, filter]); useEffect(() => { let cancelled = false; async function loadSalesExpansionOptions() { try { const data = await getExpansionOverview(""); if (!cancelled) { setSalesExpansionOptions(data.salesItems ?? []); setChannelExpansionOptions(data.channelItems ?? []); } } catch { if (!cancelled) { setSalesExpansionOptions([]); setChannelExpansionOptions([]); } } } void loadSalesExpansionOptions(); return () => { cancelled = true; }; }, []); useEffect(() => { let cancelled = false; async function loadMeta() { try { const data = await getOpportunityMeta(); if (!cancelled) { setStageOptions((data.stageOptions ?? []).filter((item) => item.value)); setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value)); setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value)); setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value)); } } catch { if (!cancelled) { setStageOptions([]); setOperatorOptions([]); setProjectLocationOptions([]); setOpportunityTypeOptions([]); } } } void loadMeta(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!stageOptions.length) { return; } const defaultStage = stageOptions[0]?.value || ""; setForm((current) => (current.stage ? current : { ...current, stage: defaultStage })); }, [stageOptions]); useEffect(() => { if (!opportunityTypeOptions.length) { return; } const defaultOpportunityType = opportunityTypeOptions[0]?.value || ""; setForm((current) => (current.opportunityType ? current : { ...current, opportunityType: defaultOpportunityType })); }, [opportunityTypeOptions]); const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? []; const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))); const stageFilterOptions = [ { label: "全部", value: "全部" }, ...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })), ].filter((item) => item.value); const normalizedProjectLocation = form.projectLocation?.trim() || ""; const projectLocationSelectOptions = [ { value: "", label: "请选择" }, ...projectLocationOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "", })), ...( normalizedProjectLocation && !projectLocationOptions.some((item) => (item.value || "").trim() === normalizedProjectLocation) ? [{ value: normalizedProjectLocation, label: normalizedProjectLocation }] : [] ), ]; const normalizedOpportunityType = form.opportunityType?.trim() || ""; const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0 ? opportunityTypeOptions : FALLBACK_OPPORTUNITY_TYPE_OPTIONS; const opportunityTypeSelectOptions = [ { value: "", label: "请选择" }, ...effectiveOpportunityTypeOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "", })), ...( normalizedOpportunityType && !effectiveOpportunityTypeOptions.some((item) => (item.value || "").trim() === normalizedOpportunityType) ? [{ value: normalizedOpportunityType, label: normalizedOpportunityType }] : [] ), ]; const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部"; const selectedSalesExpansion = selectedItem?.salesExpansionId ? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null : null; const selectedChannelExpansion = selectedItem?.channelExpansionId ? channelExpansionOptions.find((item) => item.id === selectedItem.channelExpansionId) ?? null : null; const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || ""; const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || ""; const selectedPreSalesName = selectedItem?.preSalesName || "无"; const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions); const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both"; const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both"; const showCustomCompetitorInput = selectedCompetitors.includes("其他"); const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({ value: item.id, label: item.name || `拓展人员#${item.id}`, keywords: [item.employeeNo || "", item.officeName || "", item.phone || "", item.title || ""], })); const channelExpansionSearchOptions: SearchableOption[] = channelExpansionOptions.map((item) => ({ value: item.id, label: item.name || `渠道#${item.id}`, keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""], })); const omsPreSalesSearchOptions: SearchableOption[] = [ ...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId) ? [{ value: pushPreSalesId, label: pushPreSalesName, keywords: [] }] : []), ...omsPreSalesOptions.map((item) => ({ value: item.userId, label: item.userName || item.loginName || `售前#${item.userId}`, keywords: [item.loginName || ""], })), ]; useEffect(() => { if (selectedItem) { setDetailTab("sales"); } else { setPushConfirmOpen(false); setPushPreSalesId(undefined); setPushPreSalesName(""); } }, [selectedItem]); useEffect(() => { if (selectedItem && !visibleItems.some((item) => item.id === selectedItem.id)) { setSelectedItem(null); } }, [archiveTab, selectedItem, visibleItems]); const getConfidenceColor = (score?: string | number | null) => { const normalizedGrade = normalizeConfidenceGrade(score); if (normalizedGrade === "A") return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20"; if (normalizedGrade === "B") return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20"; return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20"; }; const handleChange = (key: K, value: CreateOpportunityPayload[K]) => { setForm((current) => ({ ...current, [key]: value })); if (key in fieldErrors) { setFieldErrors((current) => { const next = { ...current }; delete next[key as OpportunityField]; return next; }); } }; const handleOpenCreate = () => { setError(""); setFieldErrors({}); setForm(defaultForm); setSelectedCompetitors([]); setCustomCompetitorName(""); setCreateOpen(true); }; const resetCreateState = () => { setCreateOpen(false); setEditOpen(false); setSubmitting(false); setError(""); setFieldErrors({}); setForm(defaultForm); setSelectedCompetitors([]); setCustomCompetitorName(""); }; const reload = async (preferredSelectedId?: number) => { const data = await getOpportunityOverview(keyword, filter); const nextItems = data.items ?? []; setItems(nextItems); if (preferredSelectedId) { setSelectedItem(nextItems.find((item) => item.id === preferredSelectedId) ?? null); } }; const handleCreateSubmit = async () => { if (submitting) { return; } setError(""); const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode); if (Object.keys(validationErrors).length > 0) { setFieldErrors(validationErrors); setError("请先完整填写商机必填字段"); return; } setSubmitting(true); try { await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode)); await reload(); resetCreateState(); } catch (createError) { setError(createError instanceof Error ? createError.message : "新增商机失败"); setSubmitting(false); } }; const handleOpenEdit = () => { if (!selectedItem) { return; } setError(""); setFieldErrors({}); setForm(toFormFromItem(selectedItem)); const competitorState = parseCompetitorState(selectedItem.competitorName); setSelectedCompetitors(competitorState.selections); setCustomCompetitorName(competitorState.customName); setEditOpen(true); }; const handleEditSubmit = async () => { if (!selectedItem || submitting) { return; } setError(""); const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode); if (Object.keys(validationErrors).length > 0) { setFieldErrors(validationErrors); setError("请先完整填写商机必填字段"); return; } setSubmitting(true); try { await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode)); await reload(selectedItem.id); resetCreateState(); } catch (updateError) { setError(updateError instanceof Error ? updateError.message : "编辑商机失败"); setSubmitting(false); } }; const handlePushToOms = async () => { if (!selectedItem || selectedItem.pushedToOms || pushingOms) { return; } setPushingOms(true); setError(""); try { const payload: PushOpportunityToOmsPayload = { preSalesId: pushPreSalesId, preSalesName: pushPreSalesName.trim() || undefined, }; await pushOpportunityToOms(selectedItem.id, payload); await reload(selectedItem.id); } catch (pushError) { setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败"); } finally { setPushingOms(false); } }; const syncPushPreSalesSelection = (item: OpportunityItem | null, options: OmsPreSalesOption[]) => { if (!item) { setPushPreSalesId(undefined); setPushPreSalesName(""); return; } const matchedById = item.preSalesId ? options.find((option) => option.userId === item.preSalesId) : undefined; if (matchedById) { setPushPreSalesId(matchedById.userId); setPushPreSalesName(matchedById.userName || matchedById.loginName || ""); return; } const matchedByName = item.preSalesName ? options.find((option) => (option.userName || "") === item.preSalesName) : undefined; if (matchedByName) { setPushPreSalesId(matchedByName.userId); setPushPreSalesName(matchedByName.userName || matchedByName.loginName || ""); return; } setPushPreSalesId(item.preSalesId); setPushPreSalesName(item.preSalesName || ""); }; const handleOpenPushConfirm = async () => { if (!selectedItem || selectedItem.pushedToOms || pushingOms) { return; } setError(""); syncPushPreSalesSelection(selectedItem, omsPreSalesOptions); setPushConfirmOpen(true); setLoadingOmsPreSales(true); try { const data = await getOpportunityOmsPreSalesOptions(); setOmsPreSalesOptions(data); syncPushPreSalesSelection(selectedItem, data); } catch (loadError) { setOmsPreSalesOptions([]); setError(loadError instanceof Error ? loadError.message : "加载售前人员失败"); } finally { setLoadingOmsPreSales(false); } }; const handleConfirmPushToOms = async () => { if (!pushPreSalesId && !pushPreSalesName.trim()) { setError("请选择售前人员"); return; } setPushConfirmOpen(false); await handlePushToOms(); }; const renderEmpty = () => (
{archiveTab === "active" ? "暂无未归档商机,先新增一条试试。" : "暂无已归档商机。"}
); return (

商机储备

维护商机基础信息,查看关联拓展对象和日报自动回写的跟进记录。

setKeyword(event.target.value)} 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" />
{visibleItems.length > 0 ? ( visibleItems.map((opp, i) => ( setSelectedItem(opp)} className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50" >

{opp.name || "未命名商机"}

商机编号:{opp.code || "待生成"}

{getConfidenceLabel(opp.confidence)} {opp.stage || "初步沟通"}
预计下单时间: {opp.date || "待定"}
预计金额: ¥{formatAmount(opp.amount)}
项目最新进展: {opp.latestProgress || "暂无回写进展"}
后续规划: {opp.nextPlan || "暂无回写规划"}
)) ) : renderEmpty()}
{stageFilterOpen ? ( <> setStageFilterOpen(false)} className="fixed inset-0 z-[90] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70" />

项目阶段筛选

按项目阶段字典筛选商机数据。

{stageFilterOptions.map((stage) => ( ))}
) : null} {(createOpen || editOpen) && (
)} >
{showSalesExpansionField ? ( ) : null} {showChannelExpansionField ? ( ) : null} {showCustomCompetitorInput ? ( ) : null} {selectedItem ? ( <>