2026-03-26 09:29:55 +00:00
|
|
|
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
|
|
|
|
import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle } from "lucide-react";
|
2026-03-19 06:23:03 +00:00
|
|
|
|
import { motion, AnimatePresence } from "motion/react";
|
2026-03-26 09:29:55 +00:00
|
|
|
|
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth";
|
|
|
|
|
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 text-[11px] transition-all hover:border-violet-200 hover:bg-violet-100 dark:border-violet-500/20 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/15";
|
|
|
|
|
|
|
|
|
|
|
|
const CONFIDENCE_OPTIONS = [
|
|
|
|
|
|
{ value: "80", label: "A" },
|
|
|
|
|
|
{ value: "60", label: "B" },
|
|
|
|
|
|
{ value: "40", label: "C" },
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
const COMPETITOR_OPTIONS = [
|
|
|
|
|
|
"深信服",
|
|
|
|
|
|
"锐捷",
|
|
|
|
|
|
"华为",
|
|
|
|
|
|
"中兴",
|
|
|
|
|
|
"噢易云",
|
|
|
|
|
|
"无",
|
|
|
|
|
|
"其他",
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | "";
|
|
|
|
|
|
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
const defaultForm: CreateOpportunityPayload = {
|
|
|
|
|
|
opportunityName: "",
|
|
|
|
|
|
customerName: "",
|
2026-03-26 09:29:55 +00:00
|
|
|
|
projectLocation: "",
|
|
|
|
|
|
operatorName: "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
amount: 0,
|
|
|
|
|
|
expectedCloseDate: "",
|
2026-03-26 09:29:55 +00:00
|
|
|
|
confidencePct: 40,
|
|
|
|
|
|
stage: "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
opportunityType: "新建",
|
|
|
|
|
|
productType: "VDI云桌面",
|
|
|
|
|
|
source: "主动开发",
|
2026-03-26 09:29:55 +00:00
|
|
|
|
salesExpansionId: undefined,
|
|
|
|
|
|
channelExpansionId: undefined,
|
|
|
|
|
|
competitorName: "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
description: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function formatAmount(value?: number) {
|
|
|
|
|
|
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
|
|
|
|
|
return "0";
|
|
|
|
|
|
}
|
|
|
|
|
|
return new Intl.NumberFormat("zh-CN").format(Number(value));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
|
|
|
|
|
return {
|
|
|
|
|
|
opportunityName: item.name || "",
|
|
|
|
|
|
customerName: item.client || "",
|
2026-03-26 09:29:55 +00:00
|
|
|
|
projectLocation: item.projectLocation || "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
expectedCloseDate: item.date || "",
|
|
|
|
|
|
confidencePct: item.confidence ?? 50,
|
2026-03-26 09:29:55 +00:00
|
|
|
|
stage: item.stageCode || item.stage || "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
opportunityType: item.type || "新建",
|
|
|
|
|
|
productType: item.product || "VDI云桌面",
|
|
|
|
|
|
source: item.source || "主动开发",
|
2026-03-26 09:29:55 +00:00
|
|
|
|
salesExpansionId: item.salesExpansionId,
|
|
|
|
|
|
channelExpansionId: item.channelExpansionId,
|
|
|
|
|
|
operatorName: item.operatorCode || item.operatorName || "",
|
|
|
|
|
|
competitorName: item.competitorName || "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
description: item.notes || "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
function getConfidenceOptionValue(score?: number) {
|
|
|
|
|
|
const value = score ?? 0;
|
|
|
|
|
|
if (value >= 80) return "80";
|
|
|
|
|
|
if (value >= 60) return "60";
|
|
|
|
|
|
return "40";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getConfidenceLabel(score?: number) {
|
|
|
|
|
|
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === getConfidenceOptionValue(score));
|
|
|
|
|
|
return matchedOption?.label || "C";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getConfidenceBadgeClass(score?: number) {
|
|
|
|
|
|
const normalizedScore = Number(getConfidenceOptionValue(score));
|
|
|
|
|
|
if (normalizedScore >= 80) return "crm-pill crm-pill-emerald";
|
|
|
|
|
|
if (normalizedScore >= 60) return "crm-pill crm-pill-amber";
|
|
|
|
|
|
return "crm-pill crm-pill-rose";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCompetitorSelection(value?: string): CompetitorOption {
|
|
|
|
|
|
const competitor = value?.trim() || "";
|
|
|
|
|
|
if (!competitor) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
return (COMPETITOR_OPTIONS as readonly string[]).includes(competitor) ? (competitor as CompetitorOption) : "其他";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeOperatorToken(value?: string) {
|
|
|
|
|
|
return (value || "")
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/\s+/g, "")
|
|
|
|
|
|
.replace(/+/g, "+");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveOperatorMode(operatorValue: string | undefined, operatorOptions: OpportunityDictOption[]): OperatorMode {
|
|
|
|
|
|
const selectedOption = operatorOptions.find((item) => (item.value || "") === (operatorValue || ""));
|
|
|
|
|
|
const candidates = [selectedOption?.label, selectedOption?.value, operatorValue]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.map((item) => normalizeOperatorToken(item));
|
|
|
|
|
|
|
|
|
|
|
|
if (!candidates.length || candidates.every((item) => !item)) {
|
|
|
|
|
|
return "none";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasH3c = candidates.some((item) => item.includes("新华三") || item.includes("h3c"));
|
|
|
|
|
|
const hasChannel = candidates.some((item) => item.includes("渠道") || item.includes("channel"));
|
|
|
|
|
|
|
|
|
|
|
|
if (hasH3c && hasChannel) {
|
|
|
|
|
|
return "both";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasH3c) {
|
|
|
|
|
|
return "h3c";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasChannel) {
|
|
|
|
|
|
return "channel";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "none";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildOpportunitySubmitPayload(
|
|
|
|
|
|
form: CreateOpportunityPayload,
|
|
|
|
|
|
competitorSelection: CompetitorOption,
|
|
|
|
|
|
operatorMode: OperatorMode,
|
|
|
|
|
|
): CreateOpportunityPayload {
|
|
|
|
|
|
const normalizedCompetitorName = competitorSelection === "其他"
|
|
|
|
|
|
? form.competitorName?.trim()
|
|
|
|
|
|
: competitorSelection || undefined;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...form,
|
|
|
|
|
|
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
|
|
|
|
|
|
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
|
|
|
|
|
|
competitorName: normalizedCompetitorName || undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function validateOperatorRelations(
|
|
|
|
|
|
operatorMode: OperatorMode,
|
|
|
|
|
|
salesExpansionId: number | undefined,
|
|
|
|
|
|
channelExpansionId: number | undefined,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (operatorMode === "h3c" && !salesExpansionId) {
|
|
|
|
|
|
throw new Error("运作方选择“新华三”时,新华三负责人必须填写");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (operatorMode === "channel" && !channelExpansionId) {
|
|
|
|
|
|
throw new Error("运作方选择“渠道”时,渠道名称必须填写");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (operatorMode === "both" && !salesExpansionId && !channelExpansionId) {
|
|
|
|
|
|
throw new Error("运作方选择“新华三+渠道”时,新华三负责人和渠道名称都必须填写");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (operatorMode === "both" && !salesExpansionId) {
|
|
|
|
|
|
throw new Error("运作方选择“新华三+渠道”时,新华三负责人必须填写");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (operatorMode === "both" && !channelExpansionId) {
|
|
|
|
|
|
throw new Error("运作方选择“新华三+渠道”时,渠道名称必须填写");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
function ModalShell({
|
|
|
|
|
|
title,
|
|
|
|
|
|
subtitle,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
children,
|
|
|
|
|
|
footer,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
subtitle: string;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
footer: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} className="fixed inset-0 z-[70] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70" />
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} 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">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</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>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
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 useIsMobileViewport() {
|
|
|
|
|
|
const [isMobile, setIsMobile] = useState(() => {
|
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return window.matchMedia("(max-width: 639px)").matches;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mediaQuery = window.matchMedia("(max-width: 639px)");
|
|
|
|
|
|
const handleChange = () => setIsMobile(mediaQuery.matches);
|
|
|
|
|
|
handleChange();
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof mediaQuery.addEventListener === "function") {
|
|
|
|
|
|
mediaQuery.addEventListener("change", handleChange);
|
|
|
|
|
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mediaQuery.addListener(handleChange);
|
|
|
|
|
|
return () => mediaQuery.removeListener(handleChange);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return isMobile;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SearchableSelect({
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
searchPlaceholder,
|
|
|
|
|
|
emptyText,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value?: number;
|
|
|
|
|
|
options: SearchableOption[];
|
|
|
|
|
|
placeholder: string;
|
|
|
|
|
|
searchPlaceholder: string;
|
|
|
|
|
|
emptyText: string;
|
|
|
|
|
|
onChange: (value?: number) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
const isMobile = useIsMobileViewport();
|
|
|
|
|
|
const selectedOption = options.find((item) => item.value === value);
|
|
|
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
|
|
|
|
const filteredOptions = options.filter((item) => {
|
|
|
|
|
|
if (!normalizedQuery) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const haystacks = [item.label, ...(item.keywords ?? [])]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.map((entry) => entry.toLowerCase());
|
|
|
|
|
|
|
|
|
|
|
|
return haystacks.some((entry) => entry.includes(normalizedQuery));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open || isMobile) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
|
|
|
|
if (!containerRef.current?.contains(event.target as Node)) {
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
setQuery("");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mousedown", handlePointerDown);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.removeEventListener("mousedown", handlePointerDown);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [isMobile, open]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open || !isMobile) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
|
|
|
|
document.body.style.overflow = "hidden";
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.body.style.overflow = previousOverflow;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [isMobile, open]);
|
|
|
|
|
|
|
|
|
|
|
|
const renderSearchBody = () => (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<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="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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<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="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"
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderSearchBody()}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 06:23:03 +00:00
|
|
|
|
export default function Opportunities() {
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const [filter, setFilter] = useState("全部");
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const [keyword, setKeyword] = useState("");
|
|
|
|
|
|
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
|
|
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
|
|
const [editOpen, setEditOpen] = useState(false);
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const [pushingOms, setPushingOms] = useState(false);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [items, setItems] = useState<OpportunityItem[]>([]);
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
|
|
|
|
|
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
|
|
|
|
|
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
|
|
|
|
|
|
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const [competitorSelection, setCompetitorSelection] = useState<CompetitorOption>("");
|
|
|
|
|
|
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
|
|
|
|
|
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSalesExpansionOptions() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getExpansionOverview("");
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setSalesExpansionOptions(data.salesItems ?? []);
|
|
|
|
|
|
setChannelExpansionOptions(data.channelItems ?? []);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setSalesExpansionOptions([]);
|
|
|
|
|
|
setChannelExpansionOptions([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void loadSalesExpansionOptions();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function loadMeta() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getOpportunityMeta();
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
|
|
|
|
|
|
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setStageOptions([]);
|
|
|
|
|
|
setOperatorOptions([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void loadMeta();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!stageOptions.length) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const defaultStage = stageOptions[0]?.value || "";
|
|
|
|
|
|
setForm((current) => (current.stage ? current : { ...current, stage: defaultStage }));
|
|
|
|
|
|
}, [stageOptions]);
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const selectedSalesExpansion = selectedItem?.salesExpansionId
|
|
|
|
|
|
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const selectedChannelExpansion = selectedItem?.channelExpansionId
|
|
|
|
|
|
? channelExpansionOptions.find((item) => item.id === selectedItem.channelExpansionId) ?? null
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
|
|
|
|
|
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
|
|
|
|
|
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
|
|
|
|
|
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
|
|
|
|
|
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
|
|
|
|
|
const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({
|
|
|
|
|
|
value: item.id,
|
|
|
|
|
|
label: item.name || `拓展人员#${item.id}`,
|
|
|
|
|
|
keywords: [item.employeeNo || "", item.officeName || "", item.phone || "", item.title || ""],
|
|
|
|
|
|
}));
|
|
|
|
|
|
const channelExpansionSearchOptions: SearchableOption[] = channelExpansionOptions.map((item) => ({
|
|
|
|
|
|
value: item.id,
|
|
|
|
|
|
label: item.name || `渠道#${item.id}`,
|
|
|
|
|
|
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedItem) {
|
|
|
|
|
|
setDetailTab("sales");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPushConfirmOpen(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedItem]);
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
const getConfidenceColor = (score: number) => {
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const normalizedScore = Number(getConfidenceOptionValue(score));
|
|
|
|
|
|
if (normalizedScore >= 80) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
|
|
|
|
|
|
if (normalizedScore >= 60) return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
|
2026-03-19 06:23:03 +00:00
|
|
|
|
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
|
|
|
|
|
|
setForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenCreate = () => {
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setForm(defaultForm);
|
2026-03-26 09:29:55 +00:00
|
|
|
|
setCompetitorSelection("");
|
2026-03-20 08:39:07 +00:00
|
|
|
|
setCreateOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetCreateState = () => {
|
|
|
|
|
|
setCreateOpen(false);
|
|
|
|
|
|
setEditOpen(false);
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setForm(defaultForm);
|
2026-03-26 09:29:55 +00:00
|
|
|
|
setCompetitorSelection("");
|
2026-03-20 08:39:07 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const reload = async (preferredSelectedId?: number) => {
|
|
|
|
|
|
const data = await getOpportunityOverview(keyword, filter);
|
|
|
|
|
|
const nextItems = data.items ?? [];
|
|
|
|
|
|
setItems(nextItems);
|
|
|
|
|
|
if (preferredSelectedId) {
|
|
|
|
|
|
setSelectedItem(nextItems.find((item) => item.id === preferredSelectedId) ?? null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreateSubmit = async () => {
|
|
|
|
|
|
if (submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-26 09:29:55 +00:00
|
|
|
|
validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId);
|
|
|
|
|
|
if (competitorSelection === "其他" && !form.competitorName?.trim()) {
|
|
|
|
|
|
throw new Error("请选择“其他”时,请填写具体竞争对手");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
2026-03-20 08:39:07 +00:00
|
|
|
|
await reload();
|
|
|
|
|
|
resetCreateState();
|
|
|
|
|
|
} catch (createError) {
|
|
|
|
|
|
setError(createError instanceof Error ? createError.message : "新增商机失败");
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenEdit = () => {
|
|
|
|
|
|
if (!selectedItem) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
if (selectedItem.pushedToOms) {
|
|
|
|
|
|
setError("该商机已推送 OMS,不能再编辑");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
setError("");
|
|
|
|
|
|
setForm(toFormFromItem(selectedItem));
|
2026-03-26 09:29:55 +00:00
|
|
|
|
setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName));
|
2026-03-20 08:39:07 +00:00
|
|
|
|
setEditOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditSubmit = async () => {
|
|
|
|
|
|
if (!selectedItem || submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-26 09:29:55 +00:00
|
|
|
|
validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId);
|
|
|
|
|
|
if (competitorSelection === "其他" && !form.competitorName?.trim()) {
|
|
|
|
|
|
throw new Error("请选择“其他”时,请填写具体竞争对手");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
2026-03-20 08:39:07 +00:00
|
|
|
|
await reload(selectedItem.id);
|
|
|
|
|
|
resetCreateState();
|
|
|
|
|
|
} catch (updateError) {
|
|
|
|
|
|
setError(updateError instanceof Error ? updateError.message : "编辑商机失败");
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const handlePushToOms = async () => {
|
|
|
|
|
|
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
2026-03-20 08:39:07 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
|
|
|
|
|
|
setPushingOms(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await pushOpportunityToOms(selectedItem.id);
|
|
|
|
|
|
await reload(selectedItem.id);
|
|
|
|
|
|
} catch (pushError) {
|
|
|
|
|
|
setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setPushingOms(false);
|
|
|
|
|
|
}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const handleOpenPushConfirm = () => {
|
|
|
|
|
|
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
2026-03-20 08:39:07 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
setPushConfirmOpen(true);
|
|
|
|
|
|
};
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const handleConfirmPushToOms = async () => {
|
|
|
|
|
|
setPushConfirmOpen(false);
|
|
|
|
|
|
await handlePushToOms();
|
2026-03-20 08:39:07 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderEmpty = () => (
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-empty-state rounded-2xl border border-slate-100 bg-white p-10 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
暂无商机数据,先新增一条试试。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-19 06:23:03 +00:00
|
|
|
|
return (
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-page-stack">
|
|
|
|
|
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">商机储备</h1>
|
|
|
|
|
|
<button onClick={handleOpenCreate} className="crm-btn-sm flex items-center gap-2 rounded-xl bg-violet-600 text-white shadow-sm transition-all hover:bg-violet-700 active:scale-95">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
<span className="hidden sm:inline">新增商机</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="relative group">
|
|
|
|
|
|
<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"
|
2026-03-26 09:29:55 +00:00
|
|
|
|
placeholder="搜索项目名称、最终客户、编码..."
|
2026-03-20 08:39:07 +00:00
|
|
|
|
value={keyword}
|
|
|
|
|
|
onChange={(event) => setKeyword(event.target.value)}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
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"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
/>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="hidden gap-2 overflow-x-auto pb-2 scrollbar-hide sm:flex">
|
|
|
|
|
|
{[
|
|
|
|
|
|
{ label: "全部", value: "全部" },
|
|
|
|
|
|
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
|
|
|
|
|
].filter((item) => item.value).map((stage) => (
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<button
|
2026-03-26 09:29:55 +00:00
|
|
|
|
key={stage.value}
|
|
|
|
|
|
onClick={() => setFilter(stage.value)}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
className={`whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200 ${
|
2026-03-26 09:29:55 +00:00
|
|
|
|
filter === stage.value
|
2026-03-20 08:39:07 +00:00
|
|
|
|
? "bg-slate-800 text-white shadow-sm dark:bg-violet-600"
|
|
|
|
|
|
: "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400 dark:hover:bg-slate-800"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{stage.label}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-list-stack">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{items.length > 0 ? (
|
|
|
|
|
|
items.map((opp, i) => (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: i * 0.05 }}
|
|
|
|
|
|
key={opp.id}
|
|
|
|
|
|
onClick={() => setSelectedItem(opp)}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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.pushedToOms ? "已推送 OMS" : "未推送 OMS"}</p>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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="ml-0.5 h-3 w-3" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</motion.div>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : renderEmpty()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<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">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<button onClick={resetCreateState} className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">取消</button>
|
|
|
|
|
|
<button onClick={() => void (editOpen ? handleEditSubmit() : handleCreateSubmit())} disabled={submitting} className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : editOpen ? "保存修改" : "确认新增"}</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-form-grid">
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目地</span>
|
|
|
|
|
|
<input value={form.projectLocation || ""} onChange={(e) => handleChange("projectLocation", 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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目名称</span>
|
|
|
|
|
|
<input value={form.opportunityName} onChange={(e) => handleChange("opportunityName", 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" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">最终客户</span>
|
|
|
|
|
|
<input value={form.customerName} onChange={(e) => handleChange("customerName", 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" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">运作方</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.operatorName || ""}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="运作方"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "", label: "请选择" },
|
|
|
|
|
|
...operatorOptions.map((item) => ({
|
|
|
|
|
|
value: item.value || "",
|
|
|
|
|
|
label: item.label || item.value || "无",
|
|
|
|
|
|
})),
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => handleChange("operatorName", value)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{showSalesExpansionField ? (
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新华三负责人</span>
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={form.salesExpansionId}
|
|
|
|
|
|
options={salesExpansionSearchOptions}
|
|
|
|
|
|
placeholder="请选择新华三负责人"
|
|
|
|
|
|
searchPlaceholder="搜索姓名、工号、办事处、电话"
|
|
|
|
|
|
emptyText="未找到匹配的销售拓展人员"
|
|
|
|
|
|
onChange={(value) => handleChange("salesExpansionId", value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{showChannelExpansionField ? (
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称</span>
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={form.channelExpansionId}
|
|
|
|
|
|
options={channelExpansionSearchOptions}
|
|
|
|
|
|
placeholder="请选择渠道名称"
|
|
|
|
|
|
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
|
|
|
|
|
|
emptyText="未找到匹配的渠道"
|
|
|
|
|
|
onChange={(value) => handleChange("channelExpansionId", value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
) : null}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目金额(元)</span>
|
|
|
|
|
|
<input type="number" min="0" value={form.amount || ""} onChange={(e) => handleChange("amount", Number(e.target.value) || 0)} 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" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计下单时间</span>
|
|
|
|
|
|
<input type="date" value={form.expectedCloseDate} onChange={(e) => handleChange("expectedCloseDate", 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" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目把握度</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={getConfidenceOptionValue(form.confidencePct)}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="项目把握度"
|
|
|
|
|
|
options={CONFIDENCE_OPTIONS.map((item) => ({ value: item.value, label: item.label }))}
|
|
|
|
|
|
onChange={(value) => handleChange("confidencePct", Number(value) || 40)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目阶段</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.stage}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="项目阶段"
|
|
|
|
|
|
options={stageOptions.map((item) => ({
|
|
|
|
|
|
value: item.value || "",
|
|
|
|
|
|
label: item.label || item.value || "无",
|
|
|
|
|
|
}))}
|
|
|
|
|
|
onChange={(value) => handleChange("stage", value)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">竞争对手</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={competitorSelection}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="竞争对手"
|
|
|
|
|
|
options={COMPETITOR_OPTIONS.map((item) => ({ value: item, label: item }))}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
const nextSelection = value as CompetitorOption;
|
|
|
|
|
|
setCompetitorSelection(nextSelection);
|
|
|
|
|
|
if (nextSelection === "其他") {
|
|
|
|
|
|
handleChange("competitorName", getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : "");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
handleChange("competitorName", nextSelection || "");
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{competitorSelection === "其他" ? (
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">其他竞争对手</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""}
|
|
|
|
|
|
onChange={(e) => handleChange("competitorName", e.target.value)}
|
|
|
|
|
|
placeholder="请输入竞争对手名称"
|
|
|
|
|
|
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>
|
|
|
|
|
|
) : null}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">扩容/新建</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.opportunityType}
|
|
|
|
|
|
sheetTitle="扩容/新建"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "新建", label: "新建" },
|
|
|
|
|
|
{ value: "扩容", label: "扩容" },
|
|
|
|
|
|
{ value: "替换", label: "替换" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => handleChange("opportunityType", value)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{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}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">备注说明</span>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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" />
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{error ? <div className="mt-4 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{error}</div> : null}
|
|
|
|
|
|
</ModalShell>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<AnimatePresence>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{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="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300">
|
|
|
|
|
|
<AlertTriangle className="h-5 w-5" />
|
|
|
|
|
|
</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 后不允许修改,是否确认推送?
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<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="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void handleConfirmPushToOms()}
|
|
|
|
|
|
disabled={pushingOms}
|
|
|
|
|
|
className="rounded-2xl bg-violet-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
{pushingOms ? "推送中..." : "确认推送"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
</motion.div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</AnimatePresence>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{selectedItem && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
|
onClick={() => setSelectedItem(null)}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
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" : ""}`}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<motion.div
|
2026-03-20 08:39:07 +00:00
|
|
|
|
initial={{ x: "100%", y: 0 }}
|
|
|
|
|
|
animate={{ x: 0, y: 0 }}
|
|
|
|
|
|
exit={{ x: "100%", y: 0 }}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
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" : ""}`}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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" />
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">商机详情</h2>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
|
|
|
|
|
|
<div className="crm-modal-stack">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<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>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{selectedItem.pushedToOms ? <span className="crm-pill crm-pill-violet px-1.5 py-0.5 text-[10px]">已推送 OMS</span> : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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 ?? 0)}`}>项目把握度 {getConfidenceLabel(selectedItem.confidence)}</span>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-section-stack">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<FileText className="h-4 w-4 text-violet-500" />
|
|
|
|
|
|
基本信息
|
|
|
|
|
|
</h4>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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={<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="OMS 推送状态" value={selectedItem.pushedToOms ? "已推送 OMS" : "未推送 OMS"} />
|
|
|
|
|
|
<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" />
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<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"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
跟进记录
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
|
|
|
|
|
|
{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-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无关联销售拓展人员
|
|
|
|
|
|
</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-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无关联拓展渠道
|
|
|
|
|
|
</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="h-4 w-4 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无交流记录
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
) : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
</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="mb-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{error}</div> : null}
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleOpenEdit}
|
|
|
|
|
|
disabled={Boolean(selectedItem.pushedToOms)}
|
|
|
|
|
|
className="inline-flex h-11 items-center justify-center rounded-xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
|
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
编辑商机
|
|
|
|
|
|
</button>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleOpenPushConfirm}
|
|
|
|
|
|
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
|
|
|
|
|
|
className="crm-btn-sm inline-flex items-center justify-center rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
|
|
|
|
|
|
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 : "无";
|
|
|
|
|
|
}
|