unis_crm/frontend/src/pages/Opportunities.tsx

2241 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, Download, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
const CONFIDENCE_OPTIONS = [
{ value: "A", label: "A" },
{ value: "B", label: "B" },
{ value: "C", label: "C" },
] as const;
const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [
{ value: "新建", label: "新建" },
{ value: "扩容", label: "扩容" },
{ value: "替换", label: "替换" },
] as const;
type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"];
const COMPETITOR_OPTIONS = [
"深信服",
"锐捷",
"华为",
"中兴",
"噢易云",
"无",
"其他",
] as const;
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
type OperatorMode = "none" | "h3c" | "channel" | "both";
type OpportunityArchiveTab = "active" | "archived";
type OpportunityField =
| "projectLocation"
| "opportunityName"
| "customerName"
| "operatorName"
| "salesExpansionId"
| "channelExpansionId"
| "amount"
| "expectedCloseDate"
| "confidencePct"
| "stage"
| "competitorName"
| "opportunityType";
const defaultForm: CreateOpportunityPayload = {
opportunityName: "",
customerName: "",
projectLocation: "",
operatorName: "",
amount: 0,
expectedCloseDate: "",
confidencePct: "C",
stage: "",
opportunityType: "",
productType: "VDI云桌面",
source: "主动开发",
salesExpansionId: undefined,
channelExpansionId: undefined,
competitorName: "",
description: "",
};
function formatAmount(value?: number) {
if (value === undefined || value === null || Number.isNaN(Number(value))) {
return "0";
}
return new Intl.NumberFormat("zh-CN").format(Number(value));
}
function 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 toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
return {
opportunityName: item.name || "",
customerName: item.client || "",
projectLocation: item.projectLocation || "",
amount: item.amount || 0,
expectedCloseDate: item.date || "",
confidencePct: normalizeConfidenceGrade(item.confidence),
stage: item.stageCode || item.stage || "",
opportunityType: item.type || "",
productType: item.product || "VDI云桌面",
source: item.source || "主动开发",
salesExpansionId: item.salesExpansionId,
channelExpansionId: item.channelExpansionId,
operatorName: item.operatorCode || item.operatorName || "",
competitorName: item.competitorName || "",
description: item.notes || "",
};
}
function normalizeConfidenceGrade(value?: string | number | null): ConfidenceGrade {
if (value === null || value === undefined) {
return "C";
}
if (typeof value === "number") {
if (value >= 80) return "A";
if (value >= 60) return "B";
return "C";
}
const normalized = value.trim().toUpperCase();
if (normalized === "A" || normalized === "B" || normalized === "C") {
return normalized;
}
if (normalized === "80") {
return "A";
}
if (normalized === "60") {
return "B";
}
return "C";
}
function getConfidenceOptionValue(score?: string | number | null) {
return normalizeConfidenceGrade(score);
}
function getConfidenceLabel(score?: string | number | null) {
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === normalizeConfidenceGrade(score));
return matchedOption?.label || "C";
}
function getConfidenceBadgeClass(score?: string | number | null) {
const normalizedGrade = normalizeConfidenceGrade(score);
if (normalizedGrade === "A") return "crm-pill crm-pill-emerald";
if (normalizedGrade === "B") return "crm-pill crm-pill-amber";
return "crm-pill crm-pill-rose";
}
function normalizeCompetitorSelections(selected: CompetitorOption[]) {
const deduped = Array.from(new Set(selected));
if (deduped.includes("无")) {
return ["无"] as CompetitorOption[];
}
return deduped;
}
function parseCompetitorState(value?: string) {
const raw = value?.trim() || "";
if (!raw) {
return {
selections: [] as CompetitorOption[],
customName: "",
};
}
const tokens = raw
.split(/[,、;\n]+/)
.map((item) => item.trim())
.filter(Boolean);
const selections: CompetitorOption[] = [];
const customValues: string[] = [];
tokens.forEach((token) => {
if ((COMPETITOR_OPTIONS as readonly string[]).includes(token) && token !== "其他") {
selections.push(token as CompetitorOption);
return;
}
customValues.push(token);
});
if (customValues.length > 0) {
selections.push("其他");
}
return {
selections: normalizeCompetitorSelections(selections),
customName: customValues.join("、"),
};
}
function buildCompetitorValue(selected: CompetitorOption[], customName?: string) {
const normalizedSelections = normalizeCompetitorSelections(selected);
if (!normalizedSelections.length) {
return undefined;
}
if (normalizedSelections.includes("无")) {
return "无";
}
const values = normalizedSelections
.filter((item) => item !== "其他")
.map((item) => item.trim());
if (normalizedSelections.includes("其他")) {
const trimmedCustomName = customName?.trim();
if (trimmedCustomName) {
values.push(trimmedCustomName);
}
}
const deduped = Array.from(new Set(values.filter(Boolean)));
return deduped.length ? deduped.join("、") : undefined;
}
function normalizeOperatorToken(value?: string) {
return (value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "")
.replace(//g, "+");
}
function resolveOperatorMode(operatorValue: string | undefined, operatorOptions: OpportunityDictOption[]): OperatorMode {
const selectedOption = operatorOptions.find((item) => (item.value || "") === (operatorValue || ""));
const candidates = [selectedOption?.label, selectedOption?.value, operatorValue]
.filter(Boolean)
.map((item) => normalizeOperatorToken(item));
if (!candidates.length || candidates.every((item) => !item)) {
return "none";
}
const hasH3c = candidates.some((item) => item.includes("新华三") || item.includes("h3c"));
const hasChannel = candidates.some((item) => item.includes("渠道") || item.includes("channel"));
if (hasH3c && hasChannel) {
return "both";
}
if (hasH3c) {
return "h3c";
}
if (hasChannel) {
return "channel";
}
return "none";
}
function buildOpportunitySubmitPayload(
form: CreateOpportunityPayload,
selectedCompetitors: CompetitorOption[],
customCompetitorName: string,
operatorMode: OperatorMode,
): CreateOpportunityPayload {
return {
...form,
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
competitorName: buildCompetitorValue(selectedCompetitors, customCompetitorName),
};
}
function validateOperatorRelations(
operatorMode: OperatorMode,
salesExpansionId: number | undefined,
channelExpansionId: number | undefined,
) {
if (operatorMode === "h3c" && !salesExpansionId) {
throw new Error("运作方选择“新华三”时,新华三负责人必须填写");
}
if (operatorMode === "channel" && !channelExpansionId) {
throw new Error("运作方选择“渠道”时,渠道名称必须填写");
}
if (operatorMode === "both" && !salesExpansionId && !channelExpansionId) {
throw new Error("运作方选择“新华三+渠道”时,新华三负责人和渠道名称都必须填写");
}
if (operatorMode === "both" && !salesExpansionId) {
throw new Error("运作方选择“新华三+渠道”时,新华三负责人必须填写");
}
if (operatorMode === "both" && !channelExpansionId) {
throw new Error("运作方选择“新华三+渠道”时,渠道名称必须填写");
}
}
function getFieldInputClass(hasError: boolean) {
return cn(
"crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50",
hasError
? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10"
: "border-slate-200 dark:border-slate-800",
);
}
function RequiredMark() {
return <span className="ml-1 text-rose-500">*</span>;
}
function validateOpportunityForm(
form: CreateOpportunityPayload,
selectedCompetitors: CompetitorOption[],
customCompetitorName: string,
operatorMode: OperatorMode,
) {
const errors: Partial<Record<OpportunityField, string>> = {};
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 (
<>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={disableMobileMotion ? undefined : { opacity: 0 }}
onClick={onClose}
className={cn("fixed inset-0 z-[70] bg-slate-900/35 dark:bg-slate-950/70", !disableMobileMotion && "backdrop-blur-sm")}
/>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={disableMobileMotion ? undefined : { opacity: 0, y: 20 }}
transition={disableMobileMotion ? { duration: 0 } : undefined}
className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
>
<div className="mx-auto flex h-[calc(100dvh-env(safe-area-inset-top))] w-full items-end sm:h-full sm:max-w-3xl sm:items-center">
<div className="flex h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:h-full sm:rounded-3xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{title}</h2>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">{subtitle}</p>
</div>
<button onClick={onClose} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
<div className="border-t border-slate-100 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 dark:border-slate-800 sm:px-6 sm:pb-4">{footer}</div>
</div>
</div>
</motion.div>
</>
);
}
function DetailItem({
label,
value,
icon,
className = "",
}: {
label: string;
value: ReactNode;
icon?: ReactNode;
className?: string;
}) {
return (
<div className={`crm-detail-item ${className}`.trim()}>
<p className="crm-detail-label">
{icon}
{label}
</p>
<div className="crm-detail-value">{value}</div>
</div>
);
}
type SearchableOption = {
value: number | string;
label: string;
keywords?: string[];
};
function dedupeSearchableOptions(options: SearchableOption[]) {
const seenValues = new Set<SearchableOption["value"]>();
return options.filter((option) => {
if (seenValues.has(option.value)) {
return false;
}
seenValues.add(option.value);
return true;
});
}
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(max-width: 639px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 639px)");
const handleChange = () => setIsMobile(mediaQuery.matches);
handleChange();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, []);
return isMobile;
}
function SearchableSelect({
value,
options,
placeholder,
searchPlaceholder,
emptyText,
className,
onChange,
}: {
value?: number;
options: SearchableOption[];
placeholder: string;
searchPlaceholder: string;
emptyText: string;
className?: string;
onChange: (value?: number) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
const containerRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const normalizedOptions = dedupeSearchableOptions(options);
const selectedOption = normalizedOptions.find((item) => item.value === value);
const normalizedQuery = query.trim().toLowerCase();
const filteredOptions = normalizedOptions.filter((item) => {
if (!normalizedQuery) {
return true;
}
const haystacks = [item.label, ...(item.keywords ?? [])]
.filter(Boolean)
.map((entry) => entry.toLowerCase());
return haystacks.some((entry) => entry.includes(normalizedQuery));
});
useEffect(() => {
if (!open || isMobile) {
return;
}
const 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));
};
updateDesktopDropdownLayout();
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
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 = () => (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
autoFocus
value={query}
onChange={(event) => 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"
/>
</div>
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
<button
type="button"
onClick={() => {
onChange(undefined);
setOpen(false);
setQuery("");
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm text-slate-500 transition-colors hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
</button>
{filteredOptions.length > 0 ? (
filteredOptions.map((item) => (
<button
type="button"
key={item.value}
onClick={() => {
onChange(Number(item.value));
setOpen(false);
setQuery("");
}}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors ${
item.value === value
? "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300"
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
<span>{item.label}</span>
{item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
))
) : (
<div className="crm-empty-state px-3 py-6">{emptyText}</div>
)}
</div>
</>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => {
setOpen((current) => {
const next = !current;
if (!next) {
setQuery("");
}
return next;
});
}}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{selectedOption?.label || placeholder}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
<AnimatePresence>
{open && !isMobile ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className={cn(
"absolute z-20 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900",
desktopDropdownPlacement === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
className="overflow-y-auto overscroll-contain pr-1"
>
{renderSearchBody()}
</div>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => {
setOpen(false);
setQuery("");
}}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{placeholder}</p>
<p className="crm-field-note mt-1"></p>
</div>
<button
type="button"
onClick={() => {
setOpen(false);
setQuery("");
}}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{renderSearchBody()}
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}
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<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const summary = value.length > 0 ? value.join("、") : placeholder;
useEffect(() => {
if (!open || isMobile) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
};
}, [isMobile, open]);
useEffect(() => {
if (!open || !isMobile) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile, open]);
const toggleOption = (option: CompetitorOption) => {
const exists = value.includes(option);
if (option === "无") {
onChange(exists ? [] : ["无"]);
return;
}
const next = exists
? value.filter((item) => item !== option)
: normalizeCompetitorSelections([...value.filter((item) => item !== "无"), option]);
onChange(next);
};
const renderOptions = () => (
<>
<div className="space-y-1">
{options.map((option) => {
const active = value.includes(option);
return (
<button
key={option}
type="button"
onClick={() => toggleOption(option)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors",
active
? "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300"
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800",
)}
>
<span>{option}</span>
{active ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
);
})}
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
<button
type="button"
onClick={() => onChange([])}
className="rounded-xl px-3 py-2 text-sm text-slate-500 transition-colors hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
>
</button>
</div>
</>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={value.length > 0 ? "truncate text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{summary}
</span>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open && "rotate-180")} />
</button>
<AnimatePresence>
{open && !isMobile ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute z-20 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div className="mb-3">
<p className="text-sm font-semibold text-slate-900 dark:text-white"></p>
<p className="crm-field-note mt-1"></p>
</div>
{renderOptions()}
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white"></p>
<p className="crm-field-note mt-1"></p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{renderOptions()}
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}
export default function Opportunities() {
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
const [filter, setFilter] = useState("全部");
const [stageFilterOpen, setStageFilterOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(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 [error, setError] = useState("");
const [exportError, setExportError] = useState("");
const [items, setItems] = useState<OpportunityItem[]>([]);
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
const [omsPreSalesOptions, setOmsPreSalesOptions] = useState<OmsPreSalesOption[]>([]);
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
const [pushPreSalesName, setPushPreSalesName] = useState("");
const [loadingOmsPreSales, setLoadingOmsPreSales] = useState(false);
const [selectedCompetitors, setSelectedCompetitors] = useState<CompetitorOption[]>([]);
const [customCompetitorName, setCustomCompetitorName] = useState("");
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
useEffect(() => {
let cancelled = false;
async function load() {
try {
const data = await getOpportunityOverview(keyword, filter);
if (!cancelled) {
setItems(data.items ?? []);
setSelectedItem(null);
}
} catch {
if (!cancelled) {
setItems([]);
setSelectedItem(null);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [keyword, filter]);
useEffect(() => {
let cancelled = false;
async function loadSalesExpansionOptions() {
try {
const data = await getExpansionOverview("");
if (!cancelled) {
setSalesExpansionOptions(data.salesItems ?? []);
setChannelExpansionOptions(data.channelItems ?? []);
}
} catch {
if (!cancelled) {
setSalesExpansionOptions([]);
setChannelExpansionOptions([]);
}
}
}
void loadSalesExpansionOptions();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
async function loadMeta() {
try {
const data = await getOpportunityMeta();
if (!cancelled) {
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
}
} catch {
if (!cancelled) {
setStageOptions([]);
setOperatorOptions([]);
setProjectLocationOptions([]);
setOpportunityTypeOptions([]);
}
}
}
void loadMeta();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!stageOptions.length) {
return;
}
const defaultStage = stageOptions[0]?.value || "";
setForm((current) => (current.stage ? current : { ...current, stage: defaultStage }));
}, [stageOptions]);
useEffect(() => {
if (!opportunityTypeOptions.length) {
return;
}
const defaultOpportunityType = opportunityTypeOptions[0]?.value || "";
setForm((current) => (current.opportunityType ? current : { ...current, opportunityType: defaultOpportunityType }));
}, [opportunityTypeOptions]);
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const stageFilterOptions = [
{ label: "全部", value: "全部" },
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
].filter((item) => item.value);
const normalizedProjectLocation = form.projectLocation?.trim() || "";
const projectLocationSelectOptions = [
{ value: "", label: "请选择" },
...projectLocationOptions.map((item) => ({
label: item.label || item.value || "",
value: item.value || "",
})),
...(
normalizedProjectLocation
&& !projectLocationOptions.some((item) => (item.value || "").trim() === normalizedProjectLocation)
? [{ value: normalizedProjectLocation, label: normalizedProjectLocation }]
: []
),
];
const normalizedOpportunityType = form.opportunityType?.trim() || "";
const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0
? opportunityTypeOptions
: FALLBACK_OPPORTUNITY_TYPE_OPTIONS;
const opportunityTypeSelectOptions = [
{ value: "", label: "请选择" },
...effectiveOpportunityTypeOptions.map((item) => ({
label: item.label || item.value || "",
value: item.value || "",
})),
...(
normalizedOpportunityType
&& !effectiveOpportunityTypeOptions.some((item) => (item.value || "").trim() === normalizedOpportunityType)
? [{ value: normalizedOpportunityType, label: normalizedOpportunityType }]
: []
),
];
const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
const selectedSalesExpansion = selectedItem?.salesExpansionId
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
: null;
const selectedChannelExpansion = selectedItem?.channelExpansionId
? channelExpansionOptions.find((item) => item.id === selectedItem.channelExpansionId) ?? null
: null;
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
const selectedPreSalesName = selectedItem?.preSalesName || "无";
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
const showCustomCompetitorInput = selectedCompetitors.includes("其他");
const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({
value: item.id,
label: item.name || `拓展人员#${item.id}`,
keywords: [item.employeeNo || "", item.officeName || "", item.phone || "", item.title || ""],
}));
const channelExpansionSearchOptions: SearchableOption[] = channelExpansionOptions.map((item) => ({
value: item.id,
label: item.name || `渠道#${item.id}`,
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
}));
const omsPreSalesSearchOptions: SearchableOption[] = [
...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId)
? [{ value: pushPreSalesId, label: pushPreSalesName, keywords: [] }]
: []),
...omsPreSalesOptions.map((item) => ({
value: item.userId,
label: item.userName || item.loginName || `售前#${item.userId}`,
keywords: [item.loginName || ""],
})),
];
useEffect(() => {
if (selectedItem) {
setDetailTab("sales");
} else {
setPushConfirmOpen(false);
setPushPreSalesId(undefined);
setPushPreSalesName("");
}
}, [selectedItem]);
useEffect(() => {
if (selectedItem && !visibleItems.some((item) => item.id === selectedItem.id)) {
setSelectedItem(null);
}
}, [archiveTab, selectedItem, visibleItems]);
const getConfidenceColor = (score?: string | number | null) => {
const normalizedGrade = normalizeConfidenceGrade(score);
if (normalizedGrade === "A") return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
if (normalizedGrade === "B") return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
};
const handleExport = async () => {
if (exporting) {
return;
}
if (visibleItems.length <= 0) {
setExportError(`当前${archiveTab === "active" ? "未归档" : "已归档"}商机暂无可导出数据`);
return;
}
setExporting(true);
setExportError("");
try {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("商机储备");
const headers = [
"项目编号",
"项目名称",
"项目地",
"最终客户",
"建设类型",
"运作方",
"项目阶段",
"项目把握度",
"预计金额(元)",
"预计下单时间",
"销售拓展人员姓名",
"销售拓展人员合作意向",
"销售拓展人员是否在职",
"拓展渠道名称",
"拓展渠道合作意向",
"拓展渠道建立联系时间",
"新华三负责人",
"售前",
"竞争对手",
"项目最新进展",
"后续规划",
"备注说明",
"跟进记录",
];
worksheet.addRow(headers);
visibleItems.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),
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),
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("最终客户")) {
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);
} catch (exportErr) {
setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
setForm((current) => ({ ...current, [key]: value }));
if (key in fieldErrors) {
setFieldErrors((current) => {
const next = { ...current };
delete next[key as OpportunityField];
return next;
});
}
};
const handleOpenCreate = () => {
setError("");
setFieldErrors({});
setForm(defaultForm);
setSelectedCompetitors([]);
setCustomCompetitorName("");
setCreateOpen(true);
};
const resetCreateState = () => {
setCreateOpen(false);
setEditOpen(false);
setSubmitting(false);
setError("");
setFieldErrors({});
setForm(defaultForm);
setSelectedCompetitors([]);
setCustomCompetitorName("");
};
const reload = async (preferredSelectedId?: number) => {
const data = await getOpportunityOverview(keyword, filter);
const nextItems = data.items ?? [];
setItems(nextItems);
if (preferredSelectedId) {
setSelectedItem(nextItems.find((item) => item.id === preferredSelectedId) ?? null);
}
};
const handleCreateSubmit = async () => {
if (submitting) {
return;
}
setError("");
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
setError("请先完整填写商机必填字段");
return;
}
setSubmitting(true);
try {
await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
await reload();
resetCreateState();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "新增商机失败");
setSubmitting(false);
}
};
const handleOpenEdit = () => {
if (!selectedItem) {
return;
}
setError("");
setFieldErrors({});
setForm(toFormFromItem(selectedItem));
const competitorState = parseCompetitorState(selectedItem.competitorName);
setSelectedCompetitors(competitorState.selections);
setCustomCompetitorName(competitorState.customName);
setEditOpen(true);
};
const handleEditSubmit = async () => {
if (!selectedItem || submitting) {
return;
}
setError("");
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
setError("请先完整填写商机必填字段");
return;
}
setSubmitting(true);
try {
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
await reload(selectedItem.id);
resetCreateState();
} catch (updateError) {
setError(updateError instanceof Error ? updateError.message : "编辑商机失败");
setSubmitting(false);
}
};
const handlePushToOms = async () => {
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
return;
}
setPushingOms(true);
setError("");
try {
const payload: PushOpportunityToOmsPayload = {
preSalesId: pushPreSalesId,
preSalesName: pushPreSalesName.trim() || undefined,
};
await pushOpportunityToOms(selectedItem.id, payload);
await reload(selectedItem.id);
} catch (pushError) {
setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败");
} finally {
setPushingOms(false);
}
};
const syncPushPreSalesSelection = (item: OpportunityItem | null, options: OmsPreSalesOption[]) => {
if (!item) {
setPushPreSalesId(undefined);
setPushPreSalesName("");
return;
}
const matchedById = item.preSalesId
? options.find((option) => option.userId === item.preSalesId)
: undefined;
if (matchedById) {
setPushPreSalesId(matchedById.userId);
setPushPreSalesName(matchedById.userName || matchedById.loginName || "");
return;
}
const matchedByName = item.preSalesName
? options.find((option) => (option.userName || "") === item.preSalesName)
: undefined;
if (matchedByName) {
setPushPreSalesId(matchedByName.userId);
setPushPreSalesName(matchedByName.userName || matchedByName.loginName || "");
return;
}
setPushPreSalesId(item.preSalesId);
setPushPreSalesName(item.preSalesName || "");
};
const handleOpenPushConfirm = async () => {
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
return;
}
setError("");
syncPushPreSalesSelection(selectedItem, omsPreSalesOptions);
setPushConfirmOpen(true);
setLoadingOmsPreSales(true);
try {
const data = await getOpportunityOmsPreSalesOptions();
setOmsPreSalesOptions(data);
syncPushPreSalesSelection(selectedItem, data);
} catch (loadError) {
setOmsPreSalesOptions([]);
setError(loadError instanceof Error ? loadError.message : "加载售前人员失败");
} finally {
setLoadingOmsPreSales(false);
}
};
const handleConfirmPushToOms = async () => {
if (!pushPreSalesId && !pushPreSalesName.trim()) {
setError("请选择售前人员");
return;
}
setPushConfirmOpen(false);
await handlePushToOms();
};
const renderEmpty = () => (
<div className="crm-empty-panel">
{archiveTab === "active" ? "暂无未归档商机,先新增一条试试。" : "暂无已归档商机。"}
</div>
);
return (
<div className="crm-page-stack">
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void handleExport()}
disabled={exporting}
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
<Download className="crm-icon-md" />
<span className="hidden sm:inline">{exporting ? "导出中..." : "导出"}</span>
</button>
<button
onClick={handleOpenCreate}
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
<Plus className="crm-icon-md" />
<span className="hidden sm:inline"></span>
</button>
</div>
</header>
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
<button
onClick={() => {
setArchiveTab("active");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
archiveTab === "active"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
</button>
<button
onClick={() => {
setArchiveTab("archived");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
archiveTab === "archived"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
</button>
</div>
<div className="flex items-center gap-3">
<div className="relative group min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" />
<input
type="text"
placeholder="搜索项目名称、最终客户、编码..."
value={keyword}
onChange={(event) => {
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"
/>
</div>
<button
type="button"
onClick={() => setStageFilterOpen(true)}
className={`relative inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border transition-all ${
filter !== "全部"
? "border-violet-200 bg-violet-50 text-violet-600 shadow-sm dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
aria-label={`项目阶段筛选,当前:${activeStageFilterLabel}`}
title={`项目阶段筛选:${activeStageFilterLabel}`}
>
<ListFilter className="h-5 w-5" />
{filter !== "全部" ? <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-violet-500" /> : null}
</button>
</div>
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
<div className="crm-list-stack">
{visibleItems.length > 0 ? (
visibleItems.map((opp, i) => (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={opp.id}
onClick={() => setSelectedItem(opp)}
className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.code || "待生成"}</p>
</div>
<div className="shrink-0 flex items-center gap-2 pl-2">
<span className={getConfidenceBadgeClass(opp.confidence)}>
{getConfidenceLabel(opp.confidence)}
</span>
<span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"}
</span>
</div>
</div>
<div className="mt-4 space-y-3 text-xs sm:text-sm">
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.date || "待定"}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.latestProgress || "暂无回写进展"}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.nextPlan || "暂无回写规划"}</span>
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}>
<ChevronRight className="crm-icon-sm ml-0.5" />
</button>
</div>
</motion.div>
))
) : renderEmpty()}
</div>
<AnimatePresence>
{stageFilterOpen ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setStageFilterOpen(false)}
className="fixed inset-0 z-[90] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[100] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 sm:inset-0 sm:flex sm:items-center sm:justify-center sm:p-6"
>
<div className="mx-auto w-full max-w-md rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-slate-200 dark:bg-slate-700 sm:hidden" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white"></h3>
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400"></p>
</div>
<button
type="button"
onClick={() => setStageFilterOpen(false)}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-4 py-4">
<div className="space-y-2">
{stageFilterOptions.map((stage) => (
<button
key={stage.value}
type="button"
onClick={() => {
setFilter(stage.value);
setExportError("");
setStageFilterOpen(false);
}}
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left text-sm font-medium transition-colors ${
filter === stage.value
? "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
: "border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
<span>{stage.label}</span>
{filter === stage.value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
))}
</div>
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
<AnimatePresence>
{(createOpen || editOpen) && (
<ModalShell
title={editOpen ? "编辑商机" : "新增商机"}
subtitle={editOpen ? "支持手机与电脑端修改商机资料,保存后会同步刷新详情与列表。" : "支持手机与电脑端填写,提交后会自动刷新商机列表。"}
onClose={resetCreateState}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetCreateState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void (editOpen ? handleEditSubmit() : handleCreateSubmit())} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : editOpen ? "保存修改" : "确认新增"}</button>
</div>
)}
>
<div className="crm-form-grid">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.projectLocation || ""}
placeholder="请选择"
sheetTitle="项目地"
searchable
searchPlaceholder="搜索项目地"
options={projectLocationSelectOptions}
className={getFieldInputClass(Boolean(fieldErrors.projectLocation))}
onChange={(value) => handleChange("projectLocation", value)}
/>
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.opportunityName} onChange={(e) => handleChange("opportunityName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.opportunityName))} />
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.customerName))} />
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.operatorName || ""}
placeholder="请选择"
sheetTitle="运作方"
options={[
{ value: "", label: "请选择" },
...operatorOptions.map((item) => ({
value: item.value || "",
label: item.label || item.value || "无",
})),
]}
className={cn(
fieldErrors.operatorName ? "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" : "",
)}
onChange={(value) => handleChange("operatorName", value)}
/>
{fieldErrors.operatorName ? <p className="text-xs text-rose-500">{fieldErrors.operatorName}</p> : null}
</label>
{showSalesExpansionField ? (
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchableSelect
value={form.salesExpansionId}
options={salesExpansionSearchOptions}
placeholder="请选择新华三负责人"
searchPlaceholder="搜索姓名、工号、办事处、电话"
emptyText="未找到匹配的销售拓展人员"
className={cn(
fieldErrors.salesExpansionId ? "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" : "",
)}
onChange={(value) => handleChange("salesExpansionId", value)}
/>
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
</label>
) : null}
{showChannelExpansionField ? (
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchableSelect
value={form.channelExpansionId}
options={channelExpansionSearchOptions}
placeholder="请选择渠道名称"
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
emptyText="未找到匹配的渠道"
className={cn(
fieldErrors.channelExpansionId ? "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" : "",
)}
onChange={(value) => handleChange("channelExpansionId", value)}
/>
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
</label>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="number" min="0" value={form.amount || ""} onChange={(e) => handleChange("amount", Number(e.target.value) || 0)} className={getFieldInputClass(Boolean(fieldErrors.amount))} />
{fieldErrors.amount ? <p className="text-xs text-rose-500">{fieldErrors.amount}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.expectedCloseDate} onChange={(e) => handleChange("expectedCloseDate", e.target.value)} className={`${getFieldInputClass(Boolean(fieldErrors.expectedCloseDate))} min-w-0`} />
{fieldErrors.expectedCloseDate ? <p className="text-xs text-rose-500">{fieldErrors.expectedCloseDate}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={getConfidenceOptionValue(form.confidencePct)}
placeholder="请选择"
sheetTitle="项目把握度"
options={CONFIDENCE_OPTIONS.map((item) => ({ value: item.value, label: item.label }))}
className={cn(
fieldErrors.confidencePct ? "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" : "",
)}
onChange={(value) => handleChange("confidencePct", normalizeConfidenceGrade(value))}
/>
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.stage}
placeholder="请选择"
sheetTitle="项目阶段"
options={stageOptions.map((item) => ({
value: item.value || "",
label: item.label || item.value || "无",
}))}
className={cn(
fieldErrors.stage ? "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" : "",
)}
onChange={(value) => handleChange("stage", value)}
/>
{fieldErrors.stage ? <p className="text-xs text-rose-500">{fieldErrors.stage}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<CompetitorMultiSelect
value={selectedCompetitors}
options={COMPETITOR_OPTIONS}
placeholder="请选择竞争对手"
className={cn(
fieldErrors.competitorName ? "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" : "",
)}
onChange={(nextValue) => {
setFieldErrors((current) => {
if (!current.competitorName) {
return current;
}
const next = { ...current };
delete next.competitorName;
return next;
});
setSelectedCompetitors(nextValue);
if (!nextValue.includes("其他")) {
setCustomCompetitorName("");
}
}}
/>
{fieldErrors.competitorName && !showCustomCompetitorInput ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
</label>
{showCustomCompetitorInput ? (
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
value={customCompetitorName}
onChange={(e) => setCustomCompetitorName(e.target.value)}
placeholder="请输入其他竞争对手"
className={getFieldInputClass(Boolean(fieldErrors.competitorName))}
/>
{fieldErrors.competitorName ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
</label>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.opportunityType}
placeholder="请选择"
sheetTitle="建设类型"
options={opportunityTypeSelectOptions}
className={cn(
fieldErrors.opportunityType ? "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" : "",
)}
onChange={(value) => handleChange("opportunityType", value)}
/>
{fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null}
</label>
{selectedItem ? (
<>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<textarea
rows={3}
readOnly
value={selectedItem.latestProgress || "暂无日报回写进展"}
className="w-full cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-800/70 dark:text-slate-400"
/>
<p className="text-xs text-slate-400 dark:text-slate-500"></p>
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<textarea
rows={3}
readOnly
value={selectedItem.nextPlan || "暂无日报回写规划"}
className="w-full cursor-not-allowed rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-800/70 dark:text-slate-400"
/>
<p className="text-xs text-slate-400 dark:text-slate-500"></p>
</label>
</>
) : null}
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<textarea rows={4} value={form.description || ""} onChange={(e) => handleChange("description", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
</label>
</div>
{error ? <div className="crm-alert crm-alert-error mt-4">{error}</div> : null}
</ModalShell>
)}
</AnimatePresence>
<AnimatePresence>
{pushConfirmOpen && selectedItem ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setPushConfirmOpen(false)}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm dark:bg-slate-950/70"
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[100] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 sm:inset-0 sm:flex sm:items-center sm:justify-center sm:p-6"
>
<div className="mx-auto w-full max-w-md rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-slate-200 dark:bg-slate-700 sm:hidden" />
<div className="flex items-start gap-3">
<div className="crm-tone-warning mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl">
<AlertTriangle className="crm-icon-lg" />
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white"> OMS</h3>
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400">
OMS OMS
</p>
</div>
</div>
</div>
<div className="px-5 py-4 text-sm text-slate-600 dark:text-slate-300 sm:px-6">
<div className="crm-form-section">
<p className="text-xs text-slate-400 dark:text-slate-500"></p>
<p className="mt-1 font-medium text-slate-900 dark:text-white">{selectedItem.name || selectedItem.code || `#${selectedItem.id}`}</p>
</div>
<div className="crm-form-section mt-4">
<p className="mb-2 text-xs text-slate-400 dark:text-slate-500"></p>
<SearchableSelect
value={pushPreSalesId}
options={omsPreSalesSearchOptions}
placeholder={loadingOmsPreSales ? "售前人员加载中..." : "请选择售前人员"}
searchPlaceholder="搜索售前姓名或登录账号"
emptyText={loadingOmsPreSales ? "正在加载售前人员..." : "未找到匹配的售前人员"}
onChange={(value) => {
const matched = omsPreSalesOptions.find((item) => item.userId === value);
setPushPreSalesId(value);
setPushPreSalesName(matched?.userName || matched?.loginName || "");
setError("");
}}
/>
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
{loadingOmsPreSales ? "正在从 OMS 拉取售前人员列表。" : "推送前必须选择售前系统会把售前ID和姓名回写到 CRM 商机表。"}
</p>
</div>
{error ? <div className="crm-alert crm-alert-error mt-4">{error}</div> : null}
</div>
<div className="flex flex-col-reverse gap-3 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-1 sm:flex-row sm:justify-end sm:px-6 sm:pb-5">
<button
type="button"
onClick={() => setPushConfirmOpen(false)}
className="crm-btn crm-btn-secondary min-h-0 rounded-2xl px-4 py-3"
>
</button>
<button
type="button"
onClick={() => void handleConfirmPushToOms()}
disabled={pushingOms}
className="crm-btn crm-btn-primary min-h-0 rounded-2xl px-4 py-3 disabled:cursor-not-allowed disabled:opacity-60"
>
{pushingOms ? "推送中..." : "确认推送"}
</button>
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
<AnimatePresence>
{selectedItem && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => 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" : ""}`}
/>
<motion.div
initial={{ x: "100%", y: 0 }}
animate={{ x: 0, y: 0 }}
exit={{ x: "100%", y: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className={`fixed inset-x-0 bottom-0 z-50 flex h-[90dvh] w-full flex-col rounded-t-3xl border border-slate-200 bg-white shadow-2xl transition-opacity dark:border-slate-800 dark:bg-slate-900 sm:inset-y-0 sm:right-0 sm:left-auto sm:h-full sm:w-[min(840px,92vw)] sm:max-w-none sm:rounded-none sm:rounded-l-3xl sm:border-l lg:w-[min(960px,90vw)] ${hasForegroundModal ? "pointer-events-none opacity-20" : ""}`}
>
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="flex items-center gap-3">
<div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" />
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg"></h2>
</div>
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="crm-icon-lg" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
<div className="crm-modal-stack">
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">{selectedItem.code || `#${selectedItem.id}`}</span>
</div>
<h3 className="line-clamp-1 text-lg font-bold leading-tight text-slate-900 dark:text-white sm:break-anywhere sm:line-clamp-none sm:text-xl">{selectedItem.name || "未命名商机"}</h3>
<div className="mt-3 flex flex-wrap gap-2">
<span className="crm-pill crm-pill-neutral">{selectedItem.stage || "初步沟通"}</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence)}`}> {getConfidenceLabel(selectedItem.confidence)}</span>
</div>
</div>
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<FileText className="crm-icon-md text-violet-500" />
</h4>
<div className="crm-detail-grid text-sm md:grid-cols-2">
<DetailItem label="项目地" value={selectedItem.projectLocation || "无"} />
<DetailItem label="最终客户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
<DetailItem label="后续规划" value={selectedItem.nextPlan || "暂无日报回写规划"} className="md:col-span-2" />
<DetailItem label="备注说明" value={selectedItem.notes || "无"} className="md:col-span-2" />
</div>
</div>
<div className="crm-section-stack">
<div className="flex rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-800/40">
<button
type="button"
onClick={() => setDetailTab("sales")}
className={`flex-1 rounded-xl px-2 py-2 text-xs font-medium transition-colors sm:px-4 sm:text-sm ${
detailTab === "sales"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
<button
type="button"
onClick={() => setDetailTab("channel")}
className={`flex-1 rounded-xl px-2 py-2 text-xs font-medium transition-colors sm:px-4 sm:text-sm ${
detailTab === "channel"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
<button
type="button"
onClick={() => setDetailTab("followups")}
className={`flex-1 rounded-xl px-2 py-2 text-xs font-medium transition-colors sm:px-4 sm:text-sm ${
detailTab === "followups"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
</div>
{detailTab === "sales" ? (
selectedSalesExpansion || selectedSalesExpansionName ? (
<div className="crm-detail-grid text-sm sm:grid-cols-3">
<DetailItem label="姓名" value={selectedSalesExpansion?.name || selectedSalesExpansionName || "无"} />
<DetailItem label="合作意向" value={selectedSalesExpansion?.intent || "无"} />
<DetailItem label="是否在职" value={selectedSalesExpansion ? (selectedSalesExpansion.active ? "是" : "否") : "待同步"} />
</div>
) : (
<div className="crm-empty-panel">
</div>
)
) : null}
{detailTab === "channel" ? (
selectedChannelExpansion || selectedChannelExpansionName ? (
<div className="crm-detail-grid text-sm sm:grid-cols-3">
<DetailItem label="渠道名称" value={selectedChannelExpansion?.name || selectedChannelExpansionName || "无"} />
<DetailItem label="合作意向" value={selectedChannelExpansion?.intent || "无"} />
<DetailItem label="建立联系时间" value={selectedChannelExpansion?.establishedDate || "无"} />
</div>
) : (
<div className="crm-empty-panel">
</div>
)
) : null}
{detailTab === "followups" ? (
<div className="crm-section-stack">
<p className="crm-field-note"></p>
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Clock className="crm-icon-md text-violet-500" />
</h4>
{followUpRecords.length > 0 ? (
<div className="relative space-y-6 border-l-2 border-slate-100 pl-4 dark:border-slate-800">
{followUpRecords.map((record) => {
const summary = getOpportunityFollowUpSummary(record);
return (
<div key={record.id} className="relative">
<div className="absolute -left-[21px] mt-1.5 h-2.5 w-2.5 rounded-full bg-violet-500 ring-4 ring-white dark:ring-slate-900" />
<div className="crm-form-section">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-500/20 dark:bg-amber-500/10">
<p className="mb-1 text-xs text-amber-700 dark:text-amber-300"></p>
<p className="whitespace-pre-wrap font-medium leading-6 text-slate-900 dark:text-white">{summary.communicationContent}</p>
</div>
<p className="mt-2 text-xs text-slate-400">: {record.user || "无"}<span className="ml-3">{record.date || "无"}</span></p>
</div>
</div>
);
})}
</div>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
</div>
) : null}
</div>
</div>
</div>
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
{error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleOpenEdit}
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center"
>
</button>
<button
type="button"
onClick={() => void handleOpenPushConfirm()}
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
className={cn(
"crm-btn inline-flex h-11 items-center justify-center",
selectedItem.pushedToOms
? "cursor-not-allowed rounded-2xl border border-slate-200 bg-slate-50 text-slate-400 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-500"
: "crm-btn-primary",
)}
>
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
</button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
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<string | undefined>) {
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 : "无";
}