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 (
<>
>
);
}
function DetailItem({
label,
value,
icon,
className = "",
}: {
label: string;
value: ReactNode;
icon?: ReactNode;
className?: string;
}) {
return (
);
}
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("");
}}
/>
{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 ? (
<>
>
) : null}
{error ? {error}
: null}
)}
{pushConfirmOpen && selectedItem ? (
<>
setPushConfirmOpen(false)}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm dark:bg-slate-950/70"
/>
确认推送 OMS
推送 OMS 后不允许修改,是否确认推送?
当前商机
{selectedItem.name || selectedItem.code || `#${selectedItem.id}`}
>
) : null}
{selectedItem && (
<>
setSelectedItem(null)}
className={`fixed inset-0 z-40 bg-slate-900/20 backdrop-blur-sm transition-opacity dark:bg-slate-900/60 ${hasForegroundModal ? "pointer-events-none opacity-30" : ""}`}
/>
{selectedItem.code || `#${selectedItem.id}`}
{selectedItem.pushedToOms ? 已推送 OMS : null}
{selectedItem.name || "未命名商机"}
{selectedItem.stage || "初步沟通"}
项目把握度 {getConfidenceLabel(selectedItem.confidence)}
基本信息
} />
} />
¥{formatAmount(selectedItem.amount)}} icon={} />
} />
} />
} />
{detailTab === "sales" ? (
selectedSalesExpansion || selectedSalesExpansionName ? (
) : (
暂无关联销售拓展人员
)
) : null}
{detailTab === "channel" ? (
selectedChannelExpansion || selectedChannelExpansionName ? (
) : (
暂无关联拓展渠道
)
) : null}
{detailTab === "followups" ? (
工作台日报里关联到该商机的工作内容,会自动回写到这里。
交流记录
{followUpRecords.length > 0 ? (
{followUpRecords.map((record) => {
const summary = getOpportunityFollowUpSummary(record);
return (
沟通内容
{summary.communicationContent}
跟进人: {record.user || "无"}{record.date || "无"}
);
})}
) : (
暂无交流记录
)}
) : null}
{error ?
{error}
: null}
>
)}
);
}
function getOpportunityFollowUpSummary(record: {
content?: string;
latestProgress?: string;
communicationContent?: string;
nextAction?: string;
}) {
const content = record.content || "";
const parsedLatestProgress = extractOpportunityFollowUpField(content, "项目最新进展");
const parsedNextPlan = extractOpportunityFollowUpField(content, "后续规划");
const communicationContent = buildOpportunityCommunicationContent(
pickOpportunityFollowUpValue(record.latestProgress, parsedLatestProgress),
pickOpportunityFollowUpValue(record.nextAction, parsedNextPlan),
pickOpportunityFollowUpValue(
record.communicationContent,
extractLegacyOpportunityCommunicationContent(extractOpportunityFollowUpField(content, "交流记录")),
),
);
return {
communicationContent: normalizeOpportunityFollowUpDisplayValue(communicationContent),
};
}
function extractOpportunityFollowUpField(content: string, label: string) {
const normalized = content.replace(/\r/g, "");
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = normalized.match(new RegExp(`${escapedLabel}:([^\\n]+)`));
return match?.[1]?.trim();
}
function extractLegacyOpportunityCommunicationContent(rawValue?: string) {
const normalized = rawValue?.trim();
if (!normalized || normalized === "无") {
return undefined;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2})\s+(.+)$/);
if (!match) {
return normalized;
}
return match[2].trim();
}
function pickOpportunityFollowUpValue(...values: Array) {
return values.find((value) => {
const normalized = value?.trim();
return normalized && normalized !== "无";
});
}
function buildOpportunityCommunicationContent(
latestProgress?: string,
nextPlan?: string,
legacyCommunicationContent?: string,
) {
const parts = [
latestProgress?.trim() ? `项目最新进展:${latestProgress.trim()}` : "",
nextPlan?.trim() ? `后续规划:${nextPlan.trim()}` : "",
].filter(Boolean);
if (parts.length > 0) {
return parts.join("\n");
}
return legacyCommunicationContent?.trim();
}
function normalizeOpportunityFollowUpDisplayValue(value?: string) {
const normalized = value?.trim();
return normalized ? normalized : "无";
}