import { useEffect, useRef, useState, type ReactNode } from "react"; import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { createPortal } from "react-dom"; import { useLocation } from "react-router-dom"; import { checkChannelExpansionDuplicate, checkSalesExpansionDuplicate, createChannelExpansion, createOpportunity, createSalesExpansion, getExpansionCityOptions, getExpansionMeta, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionContact, type ChannelExpansionItem, type CreateChannelExpansionPayload, type CreateOpportunityPayload, type CreateSalesExpansionPayload, type ExpansionDictOption, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem, } from "@/lib/auth"; import { AdaptiveSelect } from "@/components/AdaptiveSelect"; import { QuickChannelForm as SharedQuickChannelForm, QuickSalesForm as SharedQuickSalesForm, type ChannelField, type SalesCreateField, createEmptyChannelContact as createSharedEmptyChannelContact, defaultQuickChannelForm as sharedDefaultQuickChannelForm, defaultQuickSalesForm as sharedDefaultQuickSalesForm, isOtherOption as isSharedOtherOption, normalizeChannelPayload as normalizeSharedChannelPayload, normalizeSalesPayload as normalizeSharedSalesPayload, validateChannelForm as validateSharedChannelForm, validateSalesCreateForm as validateSharedSalesCreateForm, } from "@/features/crmQuickCreate/shared"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import { cn } from "@/lib/utils"; const FALLBACK_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; const COMPETITOR_OPTIONS = [ "深信服", "锐捷", "华为", "中兴", "噢易云", "无", "其他", ] as const; type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number]; type OperatorMode = "none" | "h3c" | "channel" | "both"; type OpportunityArchiveTab = "active" | "archived"; type OpportunityLocationState = { selectedId?: number; archiveTab?: OpportunityArchiveTab } | null; type OpportunityExportFilters = { keyword?: string; expectedStartDate?: string; expectedEndDate?: string; stage?: string; confidence?: string; projectLocation?: string; opportunityType?: string; operatorName?: string; hasSalesExpansion?: string; hasChannelExpansion?: string; }; type OpportunityField = | "projectLocation" | "opportunityName" | "customerName" | "operatorName" | "salesExpansionId" | "channelExpansionId" | "amount" | "expectedCloseDate" | "confidencePct" | "stage" | "competitorName" | "opportunityType"; type QuickCreateType = "sales" | "channel"; 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 formatOpportunityBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") { if (value === null || value === undefined) { return ""; } return value ? trueLabel : falseLabel; } function normalizeOpportunityExportText(value?: string | number | boolean | null) { if (value === null || value === undefined) { return ""; } const normalized = String(value).replace(/\r?\n/g, " ").trim(); if (!normalized || normalized === "无" || normalized === "待定" || normalized === "未关联") { return ""; } return normalized; } function downloadOpportunityExcelFile(filename: string, content: BlobPart) { const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const objectUrl = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(objectUrl); } function formatOpportunityExportFilenameTime(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); return `${year}${month}${day}_${hours}${minutes}${seconds}`; } function normalizeOpportunityExportFilterText(value?: string | number | boolean | null) { return normalizeOpportunityExportText(value).toLowerCase(); } function matchesOpportunityExportKeyword(value: string, keyword?: string) { const normalizedKeyword = normalizeOpportunityExportFilterText(keyword); return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword); } function matchesOpportunityTextFilter(values: Array, filterValue?: string) { const normalizedFilter = normalizeOpportunityExportFilterText(filterValue); if (!normalizedFilter) { return true; } return values.some((value) => normalizeOpportunityExportFilterText(value).includes(normalizedFilter)); } function matchesOpportunityDateRange(value?: string, startDate?: string, endDate?: string) { const normalizedValue = normalizeOpportunityExportText(value).slice(0, 10); if (!normalizedValue) { return !startDate && !endDate; } if (startDate && normalizedValue < startDate) { return false; } if (endDate && normalizedValue > endDate) { return false; } return true; } function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: string) { if (filterValue === "yes") { return hasRelation; } if (filterValue === "no") { return !hasRelation; } return true; } function matchesOpportunityExportFilters( item: OpportunityItem, filters: OpportunityExportFilters, confidenceOptions: OpportunityDictOption[], ) { const keywordText = [ item.code, item.name, item.client, item.owner, item.projectLocation, item.operatorName, item.stage, item.type, item.salesExpansionName, item.channelExpansionName, item.preSalesName, item.competitorName, item.latestProgress, item.nextPlan, item.notes, item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.latestProgress ?? ""} ${followUp.nextAction ?? ""}`).join(" "), ].map(normalizeOpportunityExportText).filter(Boolean).join(" "); if (!matchesOpportunityExportKeyword(keywordText, filters.keyword)) { return false; } if (!matchesOpportunityDateRange(item.date, filters.expectedStartDate, filters.expectedEndDate)) { return false; } if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) { return false; } if (filters.confidence && normalizeConfidenceValue(item.confidence, confidenceOptions) !== filters.confidence) { return false; } if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) { return false; } if (!matchesOpportunityTextFilter([item.type], filters.opportunityType)) { return false; } if (!matchesOpportunityTextFilter([item.operatorCode, item.operatorName], filters.operatorName)) { return false; } if (!matchesOpportunityRelationFilter(Boolean(item.salesExpansionId || item.salesExpansionName), filters.hasSalesExpansion)) { return false; } return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion); } function toFormFromItem(item: OpportunityItem, confidenceOptions: OpportunityDictOption[]): CreateOpportunityPayload { return { opportunityName: item.name || "", customerName: item.client || "", projectLocation: item.projectLocation || "", amount: item.amount || 0, expectedCloseDate: item.date || "", confidencePct: normalizeConfidenceValue(item.confidence, confidenceOptions), 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 normalizeLegacyConfidenceGrade(value?: string | number | null) { if (value === null || value === undefined) { return ""; } 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 ""; } function getEffectiveConfidenceOptions(confidenceOptions: OpportunityDictOption[]) { return confidenceOptions.length > 0 ? confidenceOptions : [...FALLBACK_CONFIDENCE_OPTIONS]; } function normalizeConfidenceValue(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) { const rawValue = typeof score === "string" ? score.trim() : typeof score === "number" ? String(score) : ""; const options = getEffectiveConfidenceOptions(confidenceOptions); if (!rawValue) { return ""; } const matchedByValue = options.find((item) => (item.value || "").trim() === rawValue); if (matchedByValue?.value) { return matchedByValue.value; } const matchedByLabel = options.find((item) => (item.label || "").trim() === rawValue); if (matchedByLabel?.value) { return matchedByLabel.value; } const legacyGrade = normalizeLegacyConfidenceGrade(score); if (!legacyGrade) { return rawValue; } const legacyValueMatch = options.find((item) => (item.value || "").trim().toUpperCase() === legacyGrade); if (legacyValueMatch?.value) { return legacyValueMatch.value; } const legacyLabelMatch = options.find((item) => (item.label || "").trim().toUpperCase() === legacyGrade); if (legacyLabelMatch?.value) { return legacyLabelMatch.value; } return legacyGrade; } function getConfidenceLabel(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) { const normalizedValue = normalizeConfidenceValue(score, confidenceOptions); const matchedOption = getEffectiveConfidenceOptions(confidenceOptions).find((item) => (item.value || "") === normalizedValue); return matchedOption?.label || matchedOption?.value || normalizedValue || "无"; } function getConfidenceBadgeClass(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) { const options = getEffectiveConfidenceOptions(confidenceOptions); const normalizedValue = normalizeConfidenceValue(score, confidenceOptions); const matchedIndex = options.findIndex((item) => (item.value || "") === normalizedValue); if (matchedIndex < 0) return "crm-pill crm-pill-rose"; if (matchedIndex === 0) return "crm-pill crm-pill-emerald"; if (matchedIndex === 1) 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}
); } function OpportunityExportFilterModal({ initialFilters, exporting, exportError, archiveTab, stageOptions, confidenceOptions, projectLocationOptions, opportunityTypeOptions, operatorOptions, onClose, onConfirm, }: { initialFilters: OpportunityExportFilters; exporting: boolean; exportError: string; archiveTab: OpportunityArchiveTab; stageOptions: OpportunityDictOption[]; confidenceOptions: OpportunityDictOption[]; projectLocationOptions: OpportunityDictOption[]; opportunityTypeOptions: OpportunityDictOption[]; operatorOptions: OpportunityDictOption[]; onClose: () => void; onConfirm: (filters: OpportunityExportFilters) => void; }) { const [draftFilters, setDraftFilters] = useState(initialFilters); const hasDraftFilters = Boolean( draftFilters.keyword || draftFilters.expectedStartDate || draftFilters.expectedEndDate || draftFilters.stage || draftFilters.confidence || draftFilters.projectLocation || draftFilters.opportunityType || draftFilters.operatorName || draftFilters.hasSalesExpansion || draftFilters.hasChannelExpansion, ); const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => { setDraftFilters((current) => ({ ...current, [key]: value })); }; const renderOption = (option: OpportunityDictOption) => { const value = option.value || option.label || ""; const label = option.label || option.value || ""; return value ? : null; }; const toSearchableOptions = (options: OpportunityDictOption[], allLabel: string) => [ { value: "", label: allLabel }, ...options .map((option) => { const value = option.value || option.label || ""; const label = option.label || option.value || ""; return value ? { value, label } : null; }) .filter((option): option is { value: string; label: string } => Boolean(option)), ]; return ( )} >
{exportError ?
{exportError}
: null}
); } type SearchableOption = { value: number | string; label: string; keywords?: string[]; }; function getSearchableOptionLabel(option: SearchableOption) { const normalizedLabel = typeof option.label === "string" ? option.label.trim() : ""; if (normalizedLabel) { return normalizedLabel; } return String(option.value ?? ""); } 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, createActionLabel, className, onChange, onCreate, onQueryChange, }: { value?: number; options: SearchableOption[]; placeholder: string; searchPlaceholder: string; emptyText: string; createActionLabel?: string; className?: string; onChange: (value?: number) => void; onCreate?: (query: string) => void; onQueryChange?: (query: string) => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom"); const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256); const containerRef = useRef(null); const desktopDropdownRef = useRef(null); const isMobile = useIsMobileViewport(); const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null); 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 = [getSearchableOptionLabel(item), ...(item.keywords ?? [])] .filter(Boolean) .map((entry) => entry.toLowerCase()); return haystacks.some((entry) => entry.includes(normalizedQuery)); }); useEffect(() => { if (!open || isMobile) { setDesktopDropdownStyle(null); return; } const updateDesktopDropdownLayout = () => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect || typeof window === "undefined") { return; } const viewportHeight = window.innerHeight; const safePadding = 24; const panelPadding = 96; const availableBelow = Math.max(160, viewportHeight - rect.bottom - safePadding - panelPadding); const availableAbove = Math.max(160, rect.top - safePadding - panelPadding); const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow; setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom"); setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow)); setDesktopDropdownStyle({ top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8) : rect.bottom + 8, left: rect.left, width: rect.width, }); }; updateDesktopDropdownLayout(); const handlePointerDown = (event: MouseEvent) => { const targetNode = event.target as Node; if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) { setOpen(false); setQuery(""); } }; const handleViewportChange = () => { updateDesktopDropdownLayout(); }; document.addEventListener("mousedown", handlePointerDown); window.addEventListener("resize", handleViewportChange); window.addEventListener("scroll", handleViewportChange, true); return () => { document.removeEventListener("mousedown", handlePointerDown); window.removeEventListener("resize", handleViewportChange); window.removeEventListener("scroll", handleViewportChange, true); }; }, [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 = () => ( <>
{ const nextQuery = event.target.value; setQuery(nextQuery); onQueryChange?.(nextQuery); }} 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}

{onCreate ? ( ) : null}
)}
); return (
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined" ? createPortal(
{renderSearchBody()}
, document.body, ) : 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 desktopDropdownRef = useRef(null); const isMobile = useIsMobileViewport(); const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom"); const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(320); const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ left: number; width: number; top: number } | null>(null); const summary = value.length > 0 ? value.join("、") : placeholder; useEffect(() => { if (!open || isMobile) { setDesktopDropdownStyle(null); return; } const updateDesktopDropdownPosition = () => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) { return; } const viewportHeight = window.innerHeight; const safePadding = 24; const panelChromeHeight = 132; const availableBelow = Math.max(180, viewportHeight - rect.bottom - safePadding - panelChromeHeight); const availableAbove = Math.max(180, rect.top - safePadding - panelChromeHeight); const shouldOpenUpward = availableBelow < 300 && availableAbove > availableBelow; const popupContentHeight = Math.min(360, shouldOpenUpward ? availableAbove : availableBelow); setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom"); setDesktopDropdownMaxHeight(popupContentHeight); setDesktopDropdownStyle({ left: rect.left, width: rect.width, top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8 - (popupContentHeight + panelChromeHeight)) : rect.bottom + 8, }); }; updateDesktopDropdownPosition(); const handlePointerDown = (event: MouseEvent) => { const targetNode = event.target as Node; if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) { setOpen(false); } }; window.addEventListener("resize", updateDesktopDropdownPosition); window.addEventListener("scroll", updateDesktopDropdownPosition, true); document.addEventListener("mousedown", handlePointerDown); return () => { window.removeEventListener("resize", updateDesktopDropdownPosition); window.removeEventListener("scroll", updateDesktopDropdownPosition, true); 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 && desktopDropdownStyle && typeof document !== "undefined" ? createPortal(

竞争对手

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

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

竞争对手

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

{renderOptions()}
) : null}
); } export default function Opportunities() { const location = useLocation(); const currentUserId = getStoredCurrentUserId(); 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 [exporting, setExporting] = useState(false); const [exportFilterOpen, setExportFilterOpen] = useState(false); const [exportFilters, setExportFilters] = useState({}); const [error, setError] = useState(""); const [exportError, setExportError] = 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 [confidenceOptions, setConfidenceOptions] = 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 [quickCreateOpen, setQuickCreateOpen] = useState(false); const [quickCreateType, setQuickCreateType] = useState("sales"); const [quickCreateSubmitting, setQuickCreateSubmitting] = useState(false); const [quickCreateError, setQuickCreateError] = useState(""); const [quickSalesForm, setQuickSalesForm] = useState(sharedDefaultQuickSalesForm); const [quickChannelForm, setQuickChannelForm] = useState(sharedDefaultQuickChannelForm); const [quickSalesFieldErrors, setQuickSalesFieldErrors] = useState>>({}); const [quickChannelFieldErrors, setQuickChannelFieldErrors] = useState>>({}); const [quickInvalidChannelContactRows, setQuickInvalidChannelContactRows] = useState([]); const [quickOfficeOptions, setQuickOfficeOptions] = useState([]); const [quickIndustryOptions, setQuickIndustryOptions] = useState([]); const [quickProvinceOptions, setQuickProvinceOptions] = useState([]); const [quickCityOptions, setQuickCityOptions] = useState([]); const [quickCertificationLevelOptions, setQuickCertificationLevelOptions] = useState([]); const [quickChannelAttributeOptions, setQuickChannelAttributeOptions] = useState([]); const [quickInternalAttributeOptions, setQuickInternalAttributeOptions] = useState([]); const [quickSalesDuplicateMessage, setQuickSalesDuplicateMessage] = useState(""); const [quickChannelDuplicateMessage, setQuickChannelDuplicateMessage] = useState(""); const [salesExpansionQuery, setSalesExpansionQuery] = useState(""); const [channelExpansionQuery, setChannelExpansionQuery] = useState(""); const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen || quickCreateOpen; 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(() => { const requestedState = location.state as OpportunityLocationState; const requestedArchiveTab = requestedState?.archiveTab; if (requestedArchiveTab === "active" || requestedArchiveTab === "archived") { setArchiveTab(requestedArchiveTab); } }, [location.state]); useEffect(() => { const requestedState = location.state as OpportunityLocationState; const requestedId = requestedState?.selectedId; if (!requestedId) { return; } const matched = items.find((item) => item.id === requestedId); if (matched) { setSelectedItem(matched); } }, [location.state, items]); 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; }; }, []); const refreshExpansionOptions = async () => { const data = await getExpansionOverview(""); setSalesExpansionOptions(data.salesItems ?? []); setChannelExpansionOptions(data.channelItems ?? []); return data; }; 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)); setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value)); } } catch { if (!cancelled) { setStageOptions([]); setOperatorOptions([]); setProjectLocationOptions([]); setOpportunityTypeOptions([]); setConfidenceOptions([]); } } } 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]); useEffect(() => { const effectiveConfidenceOptions = getEffectiveConfidenceOptions(confidenceOptions); if (!effectiveConfidenceOptions.length) { return; } const defaultConfidence = effectiveConfidenceOptions[0]?.value || ""; setForm((current) => ({ ...current, confidencePct: current.confidencePct ? normalizeConfidenceValue(current.confidencePct, effectiveConfidenceOptions) : defaultConfidence, })); }, [confidenceOptions]); const resetQuickCreateState = () => { setQuickCreateOpen(false); setQuickCreateSubmitting(false); setQuickCreateError(""); setQuickSalesForm(sharedDefaultQuickSalesForm); setQuickChannelForm(sharedDefaultQuickChannelForm); setQuickSalesFieldErrors({}); setQuickChannelFieldErrors({}); setQuickInvalidChannelContactRows([]); setQuickCityOptions([]); setQuickSalesDuplicateMessage(""); setQuickChannelDuplicateMessage(""); }; const loadQuickCreateMeta = async () => { const data = await getExpansionMeta(); setQuickOfficeOptions(data.officeOptions ?? []); setQuickIndustryOptions(data.industryOptions ?? []); setQuickProvinceOptions(data.provinceOptions ?? []); setQuickCertificationLevelOptions(data.certificationLevelOptions ?? []); setQuickChannelAttributeOptions(data.channelAttributeOptions ?? []); setQuickInternalAttributeOptions(data.internalAttributeOptions ?? []); setQuickChannelForm((current) => ({ ...current, channelCode: current.channelCode || data.nextChannelCode || "", })); return data; }; const loadQuickCityOptions = async (provinceName?: string) => { const normalizedProvinceName = provinceName?.trim(); if (!normalizedProvinceName) { setQuickCityOptions([]); return []; } const cityOptions = await getExpansionCityOptions(normalizedProvinceName); setQuickCityOptions(cityOptions ?? []); return cityOptions ?? []; }; const openQuickCreateModal = async (type: QuickCreateType, initialKeyword?: string) => { setQuickCreateType(type); setQuickCreateError(""); setQuickSalesFieldErrors({}); setQuickChannelFieldErrors({}); setQuickInvalidChannelContactRows([]); setQuickSalesDuplicateMessage(""); setQuickChannelDuplicateMessage(""); const normalizedKeyword = initialKeyword?.trim() || ""; try { const meta = await loadQuickCreateMeta(); if (type === "channel") { setQuickChannelForm({ ...sharedDefaultQuickChannelForm, channelCode: meta.nextChannelCode || "", channelName: normalizedKeyword, }); } else { setQuickSalesForm({ ...sharedDefaultQuickSalesForm, candidateName: normalizedKeyword, }); } } catch (metaError) { setQuickCreateError(metaError instanceof Error ? metaError.message : "加载拓展配置失败"); } setQuickCreateOpen(true); }; 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 effectiveConfidenceOptions = getEffectiveConfidenceOptions(confidenceOptions); const buildEmptyForm = (): CreateOpportunityPayload => ({ ...defaultForm, confidencePct: effectiveConfidenceOptions[0]?.value || defaultForm.confidencePct, }); 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 hasSelectedSalesExpansion = Boolean(selectedSalesExpansionName); const hasSelectedChannelExpansion = Boolean(selectedChannelExpansionName); const selectedPreSalesName = selectedItem?.preSalesName || "无"; const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId); const canPushSelectedItem = canEditSelectedItem; 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 quickChannelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value; 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 normalizedValue = normalizeConfidenceValue(score, effectiveConfidenceOptions); const matchedIndex = effectiveConfidenceOptions.findIndex((item) => (item.value || "") === normalizedValue); if (matchedIndex < 0) return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20"; if (matchedIndex === 0) 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 (matchedIndex === 1) 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 handleExport = async (filters: OpportunityExportFilters) => { if (exporting) { return; } setExporting(true); setExportError(""); setExportFilters(filters); try { const overview = await getOpportunityOverview("", "全部"); const exportItems = (overview.items ?? []) .filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))) .filter((item) => matchesOpportunityExportFilters(item, filters, effectiveConfidenceOptions)); if (exportItems.length <= 0) { throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未签单" : "已签单"}商机`); } const ExcelJS = await import("exceljs"); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet("商机储备"); const headers = [ "项目编号", "项目名称", "项目地", "最终用户", "建设类型", "运作方", "项目阶段", "项目把握度", "预计金额(元)", "预计下单时间", "销售拓展人员姓名", "销售拓展人员合作意向", "销售拓展人员是否在职", "拓展渠道名称", "拓展渠道合作意向", "拓展渠道建立联系时间", "新华三负责人", "售前", "竞争对手", "项目最新进展", "后续规划", "备注说明", "创建人", "更新修改时间", "是否签单", "是否推送OMS", "跟进记录", ]; worksheet.addRow(headers); exportItems.forEach((item) => { const relatedSales = item.salesExpansionId ? salesExpansionOptions.find((option) => option.id === item.salesExpansionId) ?? null : null; const relatedChannel = item.channelExpansionId ? channelExpansionOptions.find((option) => option.id === item.channelExpansionId) ?? null : null; const followUpText = (item.followUps ?? []) .map((record) => { const summary = getOpportunityFollowUpSummary(record); const lines = [ [normalizeOpportunityExportText(record.date), normalizeOpportunityExportText(record.user)].filter(Boolean).join(" / "), normalizeOpportunityExportText(summary.communicationContent), ].filter(Boolean); return lines.join("\n"); }) .filter(Boolean) .join("\n\n"); worksheet.addRow([ normalizeOpportunityExportText(item.code), normalizeOpportunityExportText(item.name), normalizeOpportunityExportText(item.projectLocation), normalizeOpportunityExportText(item.client), normalizeOpportunityExportText(item.type || "新建"), normalizeOpportunityExportText(item.operatorName), normalizeOpportunityExportText(item.stage), getConfidenceLabel(item.confidence, effectiveConfidenceOptions), 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.channelExpansionName || relatedChannel?.name), normalizeOpportunityExportText(relatedChannel?.intent), normalizeOpportunityExportText(relatedChannel?.establishedDate), normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name), normalizeOpportunityExportText(item.preSalesName), normalizeOpportunityExportText(item.competitorName), normalizeOpportunityExportText(item.latestProgress), normalizeOpportunityExportText(item.nextPlan), normalizeOpportunityExportText(item.notes), normalizeOpportunityExportText(item.owner), normalizeOpportunityExportText(item.updatedAt), formatOpportunityBoolean(item.archived, "已签单", "未签单"), formatOpportunityBoolean(item.pushedToOms, "已推送", "未推送"), followUpText, ]); }); worksheet.views = [{ state: "frozen", ySplit: 1 }]; const followUpColumnIndex = headers.indexOf("跟进记录") + 1; worksheet.getRow(1).height = 24; worksheet.getRow(1).font = { bold: true }; worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" }; headers.forEach((header, index) => { const column = worksheet.getColumn(index + 1); if (header === "跟进记录") { column.width = 42; } else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) { column.width = 24; } else if (header.includes("项目名称") || header.includes("最终客户") || header.includes("最终用户")) { column.width = 20; } else { column.width = 16; } }); worksheet.eachRow((row, rowNumber) => { row.eachCell((cell, columnNumber) => { cell.border = { top: { style: "thin", color: { argb: "FFE2E8F0" } }, left: { style: "thin", color: { argb: "FFE2E8F0" } }, bottom: { style: "thin", color: { argb: "FFE2E8F0" } }, right: { style: "thin", color: { argb: "FFE2E8F0" } }, }; cell.alignment = { vertical: "top", horizontal: rowNumber === 1 ? "center" : "left", wrapText: headers[columnNumber - 1] === "跟进记录", }; }); if (rowNumber > 1 && followUpColumnIndex > 0) { const followUpText = normalizeOpportunityExportText(row.getCell(followUpColumnIndex).value as string | null | undefined); const lineCount = followUpText ? followUpText.split("\n").length : 1; row.height = Math.max(22, lineCount * 16); } }); const buffer = await workbook.xlsx.writeBuffer(); const filename = `商机储备_${archiveTab === "active" ? "未签单" : "已签单"}_${formatOpportunityExportFilenameTime()}.xlsx`; downloadOpportunityExcelFile(filename, buffer); setExportFilterOpen(false); } catch (exportErr) { setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试"); } finally { setExporting(false); } }; 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 handleQuickSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setQuickSalesForm((current) => ({ ...current, [key]: value })); if (key in quickSalesFieldErrors) { setQuickSalesFieldErrors((current) => { const next = { ...current }; delete next[key as SalesCreateField]; return next; }); } if (key === "employeeNo") { setQuickSalesDuplicateMessage(""); } }; const handleQuickChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setQuickChannelForm((current) => ({ ...current, [key]: value })); if (key in quickChannelFieldErrors) { setQuickChannelFieldErrors((current) => { const next = { ...current }; delete next[key as ChannelField]; return next; }); } if (key === "channelName") { setQuickChannelDuplicateMessage(""); } }; const handleQuickChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string) => { setQuickChannelForm((current) => { const nextContacts = [...(current.contacts ?? [])]; nextContacts[index] = { ...(nextContacts[index] ?? createSharedEmptyChannelContact()), [key]: value, }; return { ...current, contacts: nextContacts, }; }); if (quickInvalidChannelContactRows.includes(index)) { setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); } if (quickChannelFieldErrors.contacts) { setQuickChannelFieldErrors((current) => { const next = { ...current }; delete next.contacts; return next; }); } }; const addQuickChannelContact = () => { setQuickChannelForm((current) => ({ ...current, contacts: [...(current.contacts ?? []), createSharedEmptyChannelContact()], })); }; const removeQuickChannelContact = (index: number) => { setQuickChannelForm((current) => { const currentContacts = current.contacts ?? []; const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index); return { ...current, contacts: nextContacts.length > 0 ? nextContacts : [createSharedEmptyChannelContact()], }; }); setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index).map((rowIndex) => (rowIndex > index ? rowIndex - 1 : rowIndex))); }; const handleQuickChannelProvinceChange = (value: string) => { const nextProvince = value || ""; handleQuickChannelChange("province", nextProvince || undefined); handleQuickChannelChange("city", undefined); void loadQuickCityOptions(nextProvince); }; const handleQuickCreateSubmit = async () => { if (quickCreateSubmitting) { return; } setQuickCreateError(""); setQuickSalesDuplicateMessage(""); setQuickChannelDuplicateMessage(""); if (quickCreateType === "sales") { const validationErrors = validateSharedSalesCreateForm(quickSalesForm); if (Object.keys(validationErrors).length > 0) { setQuickSalesFieldErrors(validationErrors); setQuickCreateError("请先完整填写销售人员拓展必填字段"); return; } } else { const channelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value; const { errors: validationErrors, invalidContactRows } = validateSharedChannelForm(quickChannelForm, channelOtherOptionValue); if (Object.keys(validationErrors).length > 0) { setQuickChannelFieldErrors(validationErrors); setQuickInvalidChannelContactRows(invalidContactRows); setQuickCreateError("请先完整填写渠道拓展必填字段"); return; } } setQuickCreateSubmitting(true); try { let createdId: number; if (quickCreateType === "sales") { const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!"; setQuickSalesDuplicateMessage(duplicateMessage); setQuickSalesFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage })); setQuickCreateError(duplicateMessage); setQuickCreateSubmitting(false); return; } createdId = await createSalesExpansion(normalizeSharedSalesPayload(quickSalesForm)); } else { const duplicateResult = await checkChannelExpansionDuplicate(quickChannelForm.channelName.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!"; setQuickChannelDuplicateMessage(duplicateMessage); setQuickChannelFieldErrors((current) => ({ ...current, channelName: duplicateMessage })); setQuickCreateError(duplicateMessage); setQuickCreateSubmitting(false); return; } createdId = await createChannelExpansion(normalizeSharedChannelPayload(quickChannelForm)); } const latestOverview = await refreshExpansionOptions(); if (quickCreateType === "sales") { const createdItem = (latestOverview.salesItems ?? []).find((item) => item.id === createdId); handleChange("salesExpansionId", createdItem?.id ?? createdId); } else { const createdItem = (latestOverview.channelItems ?? []).find((item) => item.id === createdId); handleChange("channelExpansionId", createdItem?.id ?? createdId); } resetQuickCreateState(); } catch (quickCreateSubmitError) { setQuickCreateError(quickCreateSubmitError instanceof Error ? quickCreateSubmitError.message : "新增失败"); setQuickCreateSubmitting(false); } }; const handleOpenCreate = () => { setError(""); setFieldErrors({}); setForm(buildEmptyForm()); setSelectedCompetitors([]); setCustomCompetitorName(""); setCreateOpen(true); }; const resetCreateState = () => { setCreateOpen(false); setEditOpen(false); setSubmitting(false); setError(""); setFieldErrors({}); setForm(buildEmptyForm()); 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; } if (!canEditSelectedItem) { setError("仅可编辑本人负责的商机"); return; } setError(""); setFieldErrors({}); setForm(toFormFromItem(selectedItem, effectiveConfidenceOptions)); const competitorState = parseCompetitorState(selectedItem.competitorName); setSelectedCompetitors(competitorState.selections); setCustomCompetitorName(competitorState.customName); setEditOpen(true); }; const handleEditSubmit = async () => { if (!selectedItem || submitting) { return; } if (!canEditSelectedItem) { setError("仅可编辑本人负责的商机"); 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 || pushingOms) { return; } if (!canPushSelectedItem) { setError("仅可推送本人负责的商机"); 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 || pushingOms) { return; } if (!canPushSelectedItem) { setError("仅可推送本人负责的商机"); 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 (!canPushSelectedItem) { setError("仅可推送本人负责的商机"); return; } if (!pushPreSalesId && !pushPreSalesName.trim()) { setError("请选择售前人员"); return; } setPushConfirmOpen(false); await handlePushToOms(); }; const renderEmpty = () => (
{archiveTab === "active" ? "暂无未签单商机,先新增一条试试。" : "暂无已签单商机。"}
); return (

商机储备

{ setKeyword(event.target.value); setExportError(""); }} 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" />
{exportError ?
{exportError}
: null}
{visibleItems.length > 0 ? ( visibleItems.map((opp, i) => { const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId; return ( setSelectedItem(opp)} className={cn( "crm-card crm-card-pad group relative rounded-2xl transition-shadow transition-colors", isOwnedByCurrentUser ? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50" : "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700", )} > {isOwnedByCurrentUser ? (
) : null}

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

项目编号:{opp.code || "待生成"}

{getConfidenceLabel(opp.confidence, effectiveConfidenceOptions)} {opp.stage || "初步沟通"} {opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
预计下单时间: {opp.date || "待定"}
预计金额: ¥{formatAmount(opp.amount)}
项目最新进展: {opp.latestProgress || "暂无回写进展"}
后续规划: {opp.nextPlan || "暂无回写规划"}
创建人: {opp.owner || "无"}
); }) ) : renderEmpty()}
{exportFilterOpen ? ( setExportFilterOpen(false)} onConfirm={(filters) => void handleExport(filters)} /> ) : null} {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 ? ( <>