import { useEffect, useRef, useState, type ReactNode } from "react"; import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth"; import { AdaptiveSelect } from "@/components/AdaptiveSelect"; const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 text-[11px] transition-all hover:border-violet-200 hover:bg-violet-100 dark:border-violet-500/20 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/15"; const CONFIDENCE_OPTIONS = [ { value: "80", label: "A" }, { value: "60", label: "B" }, { value: "40", label: "C" }, ] as const; const COMPETITOR_OPTIONS = [ "深信服", "锐捷", "华为", "中兴", "噢易云", "无", "其他", ] as const; type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | ""; type OperatorMode = "none" | "h3c" | "channel" | "both"; const defaultForm: CreateOpportunityPayload = { opportunityName: "", customerName: "", projectLocation: "", operatorName: "", amount: 0, expectedCloseDate: "", confidencePct: 40, 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: item.confidence ?? 50, 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 getConfidenceOptionValue(score?: number) { const value = score ?? 0; if (value >= 80) return "80"; if (value >= 60) return "60"; return "40"; } function getConfidenceLabel(score?: number) { const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === getConfidenceOptionValue(score)); return matchedOption?.label || "C"; } function getConfidenceBadgeClass(score?: number) { const normalizedScore = Number(getConfidenceOptionValue(score)); if (normalizedScore >= 80) return "crm-pill crm-pill-emerald"; if (normalizedScore >= 60) return "crm-pill crm-pill-amber"; return "crm-pill crm-pill-rose"; } function getCompetitorSelection(value?: string): CompetitorOption { const competitor = value?.trim() || ""; if (!competitor) { return ""; } return (COMPETITOR_OPTIONS as readonly string[]).includes(competitor) ? (competitor as CompetitorOption) : "其他"; } 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, competitorSelection: CompetitorOption, operatorMode: OperatorMode, ): CreateOpportunityPayload { const normalizedCompetitorName = competitorSelection === "其他" ? form.competitorName?.trim() : competitorSelection || undefined; return { ...form, salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined, channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined, competitorName: normalizedCompetitorName || undefined, }; } 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 ModalShell({ title, subtitle, onClose, children, footer, }: { title: string; subtitle: string; onClose: () => void; children: ReactNode; footer: ReactNode; }) { 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 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, onChange, }: { value?: number; options: SearchableOption[]; placeholder: string; searchPlaceholder: string; emptyText: string; onChange: (value?: number) => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const containerRef = useRef(null); const isMobile = useIsMobileViewport(); const selectedOption = options.find((item) => item.value === value); const normalizedQuery = query.trim().toLowerCase(); const filteredOptions = options.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}
); } export default function Opportunities() { const [filter, setFilter] = useState("全部"); 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 [stageOptions, setStageOptions] = useState([]); const [operatorOptions, setOperatorOptions] = useState([]); const [form, setForm] = useState(defaultForm); const [competitorSelection, setCompetitorSelection] = 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)); } } catch { if (!cancelled) { setStageOptions([]); setOperatorOptions([]); } } } void loadMeta(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!stageOptions.length) { return; } const defaultStage = stageOptions[0]?.value || ""; setForm((current) => (current.stage ? current : { ...current, stage: defaultStage })); }, [stageOptions]); const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? []; 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 operatorMode = resolveOperatorMode(form.operatorName, operatorOptions); const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both"; const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both"; 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 || ""], })); useEffect(() => { if (selectedItem) { setDetailTab("sales"); } else { setPushConfirmOpen(false); } }, [selectedItem]); const getConfidenceColor = (score: number) => { const normalizedScore = Number(getConfidenceOptionValue(score)); if (normalizedScore >= 80) 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 (normalizedScore >= 60) 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 })); }; const handleOpenCreate = () => { setError(""); setForm(defaultForm); setCompetitorSelection(""); setCreateOpen(true); }; const resetCreateState = () => { setCreateOpen(false); setEditOpen(false); setSubmitting(false); setError(""); setForm(defaultForm); setCompetitorSelection(""); }; 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; } setSubmitting(true); setError(""); try { validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId); if (competitorSelection === "其他" && !form.competitorName?.trim()) { throw new Error("请选择“其他”时,请填写具体竞争对手"); } await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode)); await reload(); resetCreateState(); } catch (createError) { setError(createError instanceof Error ? createError.message : "新增商机失败"); setSubmitting(false); } }; const handleOpenEdit = () => { if (!selectedItem) { return; } if (selectedItem.pushedToOms) { setError("该商机已推送 OMS,不能再编辑"); return; } setError(""); setForm(toFormFromItem(selectedItem)); setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName)); setEditOpen(true); }; const handleEditSubmit = async () => { if (!selectedItem || submitting) { return; } setSubmitting(true); setError(""); try { validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId); if (competitorSelection === "其他" && !form.competitorName?.trim()) { throw new Error("请选择“其他”时,请填写具体竞争对手"); } await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, 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 { await pushOpportunityToOms(selectedItem.id); await reload(selectedItem.id); } catch (pushError) { setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败"); } finally { setPushingOms(false); } }; const handleOpenPushConfirm = () => { if (!selectedItem || selectedItem.pushedToOms || pushingOms) { return; } setPushConfirmOpen(true); }; const handleConfirmPushToOms = async () => { setPushConfirmOpen(false); await handlePushToOms(); }; const renderEmpty = () => (
暂无商机数据,先新增一条试试。
); 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" />
{[ { label: "全部", value: "全部" }, ...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })), ].filter((item) => item.value).map((stage) => ( ))}
{items.length > 0 ? ( items.map((opp, i) => ( setSelectedItem(opp)} className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50" >

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

{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}

{getConfidenceLabel(opp.confidence)} {opp.stage || "初步沟通"}
预计下单时间: {opp.date || "待定"}
项目金额: ¥{formatAmount(opp.amount)}
项目最新进展: {opp.latestProgress || "暂无回写进展"}
后续规划: {opp.nextPlan || "暂无回写规划"}
)) ) : renderEmpty()}
{(createOpen || editOpen) && (
)} >
{showSalesExpansionField ? ( ) : null} {showChannelExpansionField ? ( ) : null} {competitorSelection === "其他" ? ( ) : null} {selectedItem ? ( <>