2241 lines
95 KiB
TypeScript
2241 lines
95 KiB
TypeScript
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 : "无";
|
||
}
|