unis_crm/frontend/src/pages/Opportunities.tsx

2804 lines
121 KiB
TypeScript
Raw Normal View History

2026-03-26 09:29:55 +00:00
import { useEffect, useRef, useState, type ReactNode } from "react";
2026-04-03 02:11:19 +00:00
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
2026-03-19 06:23:03 +00:00
import { motion, AnimatePresence } from "motion/react";
2026-04-03 06:35:30 +00:00
import { createPortal } from "react-dom";
2026-04-02 09:26:35 +00:00
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
2026-03-26 09:29:55 +00:00
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
2026-03-27 09:05:41 +00:00
import { cn } from "@/lib/utils";
2026-03-20 08:39:07 +00:00
const FALLBACK_CONFIDENCE_OPTIONS = [
2026-04-01 09:24:06 +00:00
{ value: "A", label: "A" },
{ value: "B", label: "B" },
{ value: "C", label: "C" },
2026-03-26 09:29:55 +00:00
] as const;
2026-04-02 02:07:21 +00:00
const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [
{ value: "新建", label: "新建" },
{ value: "扩容", label: "扩容" },
{ value: "替换", label: "替换" },
] as const;
2026-03-26 09:29:55 +00:00
const COMPETITOR_OPTIONS = [
"深信服",
"锐捷",
"华为",
"中兴",
"噢易云",
"无",
"其他",
] as const;
2026-04-01 09:24:06 +00:00
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
2026-03-26 09:29:55 +00:00
type OperatorMode = "none" | "h3c" | "channel" | "both";
2026-03-27 09:05:41 +00:00
type OpportunityArchiveTab = "active" | "archived";
2026-04-08 09:41:17 +00:00
type OpportunityExportFilters = {
keyword?: string;
expectedStartDate?: string;
expectedEndDate?: string;
stage?: string;
confidence?: string;
projectLocation?: string;
opportunityType?: string;
operatorName?: string;
hasSalesExpansion?: string;
hasChannelExpansion?: string;
};
2026-03-27 09:05:41 +00:00
type OpportunityField =
| "projectLocation"
| "opportunityName"
| "customerName"
| "operatorName"
| "salesExpansionId"
| "channelExpansionId"
| "amount"
| "expectedCloseDate"
| "confidencePct"
| "stage"
| "competitorName"
| "opportunityType";
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-04-01 09:24:06 +00:00
confidencePct: "C",
2026-03-26 09:29:55 +00:00
stage: "",
2026-04-02 02:07:21 +00:00
opportunityType: "",
2026-03-20 08:39:07 +00:00
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));
}
2026-04-15 08:21:52 +00:00
function formatOpportunityBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") {
if (value === null || value === undefined) {
return "";
}
return value ? trueLabel : falseLabel;
}
2026-04-02 05:19:22 +00:00
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}`;
}
2026-04-08 09:41:17 +00:00
function normalizeOpportunityExportFilterText(value?: string | number | boolean | null) {
return normalizeOpportunityExportText(value).toLowerCase();
}
function matchesOpportunityExportKeyword(value: string, keyword?: string) {
const normalizedKeyword = normalizeOpportunityExportFilterText(keyword);
return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword);
}
function matchesOpportunityTextFilter(values: Array<string | number | boolean | null | undefined>, filterValue?: string) {
const normalizedFilter = normalizeOpportunityExportFilterText(filterValue);
if (!normalizedFilter) {
return true;
}
return values.some((value) => normalizeOpportunityExportFilterText(value).includes(normalizedFilter));
}
function matchesOpportunityDateRange(value?: string, startDate?: string, endDate?: string) {
const normalizedValue = normalizeOpportunityExportText(value).slice(0, 10);
if (!normalizedValue) {
return !startDate && !endDate;
}
if (startDate && normalizedValue < startDate) {
return false;
}
if (endDate && normalizedValue > endDate) {
return false;
}
return true;
}
function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: string) {
if (filterValue === "yes") {
return hasRelation;
}
if (filterValue === "no") {
return !hasRelation;
}
return true;
}
function matchesOpportunityExportFilters(
item: OpportunityItem,
filters: OpportunityExportFilters,
confidenceOptions: OpportunityDictOption[],
) {
2026-04-08 09:41:17 +00:00
const keywordText = [
item.code,
item.name,
item.client,
item.owner,
item.projectLocation,
item.operatorName,
item.stage,
item.type,
item.salesExpansionName,
item.channelExpansionName,
item.preSalesName,
item.competitorName,
item.latestProgress,
item.nextPlan,
item.notes,
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.latestProgress ?? ""} ${followUp.nextAction ?? ""}`).join(" "),
].map(normalizeOpportunityExportText).filter(Boolean).join(" ");
if (!matchesOpportunityExportKeyword(keywordText, filters.keyword)) {
return false;
}
if (!matchesOpportunityDateRange(item.date, filters.expectedStartDate, filters.expectedEndDate)) {
return false;
}
if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) {
return false;
}
if (filters.confidence && normalizeConfidenceValue(item.confidence, confidenceOptions) !== filters.confidence) {
2026-04-08 09:41:17 +00:00
return false;
}
if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) {
return false;
}
if (!matchesOpportunityTextFilter([item.type], filters.opportunityType)) {
return false;
}
if (!matchesOpportunityTextFilter([item.operatorCode, item.operatorName], filters.operatorName)) {
return false;
}
if (!matchesOpportunityRelationFilter(Boolean(item.salesExpansionId || item.salesExpansionName), filters.hasSalesExpansion)) {
return false;
}
return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion);
}
function toFormFromItem(item: OpportunityItem, confidenceOptions: OpportunityDictOption[]): CreateOpportunityPayload {
2026-03-20 08:39:07 +00:00
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: normalizeConfidenceValue(item.confidence, confidenceOptions),
2026-03-26 09:29:55 +00:00
stage: item.stageCode || item.stage || "",
2026-04-02 02:07:21 +00:00
opportunityType: item.type || "",
2026-03-20 08:39:07 +00:00
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 || "",
};
}
function normalizeLegacyConfidenceGrade(value?: string | number | null) {
2026-04-01 09:24:06 +00:00
if (value === null || value === undefined) {
return "";
2026-04-01 09:24:06 +00:00
}
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 "";
2026-04-01 09:24:06 +00:00
}
function getEffectiveConfidenceOptions(confidenceOptions: OpportunityDictOption[]) {
return confidenceOptions.length > 0 ? confidenceOptions : [...FALLBACK_CONFIDENCE_OPTIONS];
2026-03-26 09:29:55 +00:00
}
function normalizeConfidenceValue(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) {
const rawValue = typeof score === "string" ? score.trim() : typeof score === "number" ? String(score) : "";
const options = getEffectiveConfidenceOptions(confidenceOptions);
if (!rawValue) {
return "";
}
const matchedByValue = options.find((item) => (item.value || "").trim() === rawValue);
if (matchedByValue?.value) {
return matchedByValue.value;
}
const matchedByLabel = options.find((item) => (item.label || "").trim() === rawValue);
if (matchedByLabel?.value) {
return matchedByLabel.value;
}
const legacyGrade = normalizeLegacyConfidenceGrade(score);
if (!legacyGrade) {
return rawValue;
}
const legacyValueMatch = options.find((item) => (item.value || "").trim().toUpperCase() === legacyGrade);
if (legacyValueMatch?.value) {
return legacyValueMatch.value;
}
const legacyLabelMatch = options.find((item) => (item.label || "").trim().toUpperCase() === legacyGrade);
if (legacyLabelMatch?.value) {
return legacyLabelMatch.value;
}
return legacyGrade;
2026-03-26 09:29:55 +00:00
}
function getConfidenceLabel(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) {
const normalizedValue = normalizeConfidenceValue(score, confidenceOptions);
const matchedOption = getEffectiveConfidenceOptions(confidenceOptions).find((item) => (item.value || "") === normalizedValue);
return matchedOption?.label || matchedOption?.value || normalizedValue || "无";
}
function getConfidenceBadgeClass(score: string | number | null | undefined, confidenceOptions: OpportunityDictOption[]) {
const options = getEffectiveConfidenceOptions(confidenceOptions);
const normalizedValue = normalizeConfidenceValue(score, confidenceOptions);
const matchedIndex = options.findIndex((item) => (item.value || "") === normalizedValue);
if (matchedIndex < 0) return "crm-pill crm-pill-rose";
if (matchedIndex === 0) return "crm-pill crm-pill-emerald";
if (matchedIndex === 1) return "crm-pill crm-pill-amber";
2026-03-26 09:29:55 +00:00
return "crm-pill crm-pill-rose";
}
2026-04-01 09:24:06 +00:00
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 "无";
2026-03-26 09:29:55 +00:00
}
2026-04-01 09:24:06 +00:00
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;
2026-03-26 09:29:55 +00:00
}
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,
2026-04-01 09:24:06 +00:00
selectedCompetitors: CompetitorOption[],
customCompetitorName: string,
2026-03-26 09:29:55 +00:00
operatorMode: OperatorMode,
): CreateOpportunityPayload {
return {
...form,
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
2026-04-01 09:24:06 +00:00
competitorName: buildCompetitorValue(selectedCompetitors, customCompetitorName),
2026-03-26 09:29:55 +00:00
};
}
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-27 09:05:41 +00:00
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,
2026-04-01 09:24:06 +00:00
selectedCompetitors: CompetitorOption[],
customCompetitorName: string,
2026-03-27 09:05:41 +00:00
operatorMode: OperatorMode,
) {
const errors: Partial<Record<OpportunityField, string>> = {};
if (!form.projectLocation?.trim()) {
2026-04-01 09:24:06 +00:00
errors.projectLocation = "请选择项目地";
2026-03-27 09:05:41 +00:00
}
if (!form.opportunityName?.trim()) {
errors.opportunityName = "请填写项目名称";
}
if (!form.customerName?.trim()) {
2026-04-02 09:26:35 +00:00
errors.customerName = "请填写最终用户";
2026-03-27 09:05:41 +00:00
}
if (!form.operatorName?.trim()) {
errors.operatorName = "请选择运作方";
}
if (!form.amount || form.amount <= 0) {
errors.amount = "请填写预计金额";
}
if (!form.expectedCloseDate?.trim()) {
errors.expectedCloseDate = "请选择预计下单时间";
}
2026-04-01 09:24:06 +00:00
if (!form.confidencePct?.trim()) {
2026-03-27 09:05:41 +00:00
errors.confidencePct = "请选择项目把握度";
}
if (!form.stage?.trim()) {
errors.stage = "请选择项目阶段";
}
if (!form.opportunityType?.trim()) {
errors.opportunityType = "请选择建设类型";
}
2026-04-01 09:24:06 +00:00
if (selectedCompetitors.length === 0) {
errors.competitorName = "请至少选择一个竞争对手";
} else if (selectedCompetitors.includes("其他") && !customCompetitorName.trim()) {
errors.competitorName = "已选择“其他”,请填写其他竞争对手";
2026-03-27 09:05:41 +00:00
}
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;
}
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;
}) {
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
2026-03-20 08:39:07 +00:00
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"
>
2026-03-26 09:29:55 +00:00
<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>
);
}
2026-04-08 09:41:17 +00:00
function OpportunityExportFilterModal({
initialFilters,
exporting,
exportError,
archiveTab,
stageOptions,
confidenceOptions,
2026-04-08 09:41:17 +00:00
projectLocationOptions,
opportunityTypeOptions,
operatorOptions,
onClose,
onConfirm,
}: {
initialFilters: OpportunityExportFilters;
exporting: boolean;
exportError: string;
archiveTab: OpportunityArchiveTab;
stageOptions: OpportunityDictOption[];
confidenceOptions: OpportunityDictOption[];
2026-04-08 09:41:17 +00:00
projectLocationOptions: OpportunityDictOption[];
opportunityTypeOptions: OpportunityDictOption[];
operatorOptions: OpportunityDictOption[];
onClose: () => void;
onConfirm: (filters: OpportunityExportFilters) => void;
}) {
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(initialFilters);
const hasDraftFilters = Boolean(
draftFilters.keyword
|| draftFilters.expectedStartDate
|| draftFilters.expectedEndDate
|| draftFilters.stage
|| draftFilters.confidence
|| draftFilters.projectLocation
|| draftFilters.opportunityType
|| draftFilters.operatorName
|| draftFilters.hasSalesExpansion
|| draftFilters.hasChannelExpansion,
);
const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => {
setDraftFilters((current) => ({ ...current, [key]: value }));
};
const renderOption = (option: OpportunityDictOption) => {
const value = option.value || option.label || "";
const label = option.label || option.value || "";
return value ? <option key={value} value={value}>{label}</option> : null;
};
const toSearchableOptions = (options: OpportunityDictOption[], allLabel: string) => [
{ value: "", label: allLabel },
...options
.map((option) => {
const value = option.value || option.label || "";
const label = option.label || option.value || "";
return value ? { value, label } : null;
})
.filter((option): option is { value: string; label: string } => Boolean(option)),
];
return (
<ModalShell
2026-04-15 08:21:52 +00:00
title={`导出${archiveTab === "active" ? "未签单" : "已签单"}商机`}
subtitle="选择条件后导出 Excel不填条件则导出当前签单页签下的全部可见商机。"
2026-04-08 09:41:17 +00:00
onClose={onClose}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<button
type="button"
onClick={() => setDraftFilters({})}
disabled={!hasDraftFilters}
className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={() => onConfirm(draftFilters)}
disabled={exporting}
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{exporting ? "导出中..." : "确认导出"}
</button>
</div>
)}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
value={draftFilters.keyword ?? ""}
onChange={(event) => handleFilterChange("keyword", event.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>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.expectedStartDate ?? ""}
onChange={(event) => handleFilterChange("expectedStartDate", event.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>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.expectedEndDate ?? ""}
onChange={(event) => handleFilterChange("expectedEndDate", event.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>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.stage ?? ""}
options={toSearchableOptions(stageOptions, "全部阶段")}
placeholder="全部阶段"
sheetTitle="选择项目阶段"
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"
onChange={(value) => handleFilterChange("stage", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.confidence ?? ""}
options={[
{ value: "", label: "全部把握度" },
...getEffectiveConfidenceOptions(confidenceOptions).map((option) => ({
value: option.value || "",
label: option.label || option.value || "",
})),
2026-04-08 09:41:17 +00:00
]}
placeholder="全部把握度"
sheetTitle="选择项目把握度"
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"
onChange={(value) => handleFilterChange("confidence", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.projectLocation ?? ""}
options={toSearchableOptions(projectLocationOptions, "全部项目地")}
placeholder="全部项目地"
sheetTitle="选择项目地"
searchable
searchPlaceholder="搜索项目地"
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"
onChange={(value) => handleFilterChange("projectLocation", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.opportunityType ?? ""}
options={toSearchableOptions(opportunityTypeOptions, "全部建设类型")}
placeholder="全部建设类型"
sheetTitle="选择建设类型"
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"
onChange={(value) => handleFilterChange("opportunityType", value)}
/>
</label>
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.operatorName ?? ""}
options={toSearchableOptions(operatorOptions, "全部运作方")}
placeholder="全部运作方"
sheetTitle="选择运作方"
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"
onChange={(value) => handleFilterChange("operatorName", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.hasSalesExpansion ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "yes", label: "已关联" },
{ value: "no", label: "未关联" },
]}
placeholder="全部"
sheetTitle="选择新华三负责人关联状态"
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"
onChange={(value) => handleFilterChange("hasSalesExpansion", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.hasChannelExpansion ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "yes", label: "已关联" },
{ value: "no", label: "未关联" },
]}
placeholder="全部"
sheetTitle="选择拓展渠道关联状态"
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"
onChange={(value) => handleFilterChange("hasChannelExpansion", value)}
/>
</label>
</div>
{exportError ? <div className="crm-alert crm-alert-error mt-4">{exportError}</div> : null}
</ModalShell>
);
}
2026-03-26 09:29:55 +00:00
type SearchableOption = {
value: number | string;
label: string;
keywords?: string[];
};
2026-04-03 06:35:30 +00:00
function getSearchableOptionLabel(option: SearchableOption) {
const normalizedLabel = typeof option.label === "string" ? option.label.trim() : "";
if (normalizedLabel) {
return normalizedLabel;
}
return String(option.value ?? "");
}
2026-04-01 09:24:06 +00:00
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;
});
}
2026-03-26 09:29:55 +00:00
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,
2026-03-27 09:05:41 +00:00
className,
2026-03-26 09:29:55 +00:00
onChange,
}: {
value?: number;
options: SearchableOption[];
placeholder: string;
searchPlaceholder: string;
emptyText: string;
2026-03-27 09:05:41 +00:00
className?: string;
2026-03-26 09:29:55 +00:00
onChange: (value?: number) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
2026-04-02 05:19:22 +00:00
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
2026-03-26 09:29:55 +00:00
const containerRef = useRef<HTMLDivElement | null>(null);
2026-04-03 06:35:30 +00:00
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
2026-03-26 09:29:55 +00:00
const isMobile = useIsMobileViewport();
2026-04-03 06:35:30 +00:00
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
2026-04-01 09:24:06 +00:00
const normalizedOptions = dedupeSearchableOptions(options);
const selectedOption = normalizedOptions.find((item) => item.value === value);
2026-03-26 09:29:55 +00:00
const normalizedQuery = query.trim().toLowerCase();
2026-04-01 09:24:06 +00:00
const filteredOptions = normalizedOptions.filter((item) => {
2026-03-26 09:29:55 +00:00
if (!normalizedQuery) {
return true;
}
2026-04-03 06:35:30 +00:00
const haystacks = [getSearchableOptionLabel(item), ...(item.keywords ?? [])]
2026-03-26 09:29:55 +00:00
.filter(Boolean)
.map((entry) => entry.toLowerCase());
return haystacks.some((entry) => entry.includes(normalizedQuery));
});
useEffect(() => {
if (!open || isMobile) {
2026-04-03 06:35:30 +00:00
setDesktopDropdownStyle(null);
2026-03-26 09:29:55 +00:00
return;
}
2026-04-02 05:19:22 +00:00
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));
2026-04-03 06:35:30 +00:00
setDesktopDropdownStyle({
top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8) : rect.bottom + 8,
left: rect.left,
width: rect.width,
});
2026-04-02 05:19:22 +00:00
};
updateDesktopDropdownLayout();
2026-03-26 09:29:55 +00:00
const handlePointerDown = (event: MouseEvent) => {
2026-04-03 06:35:30 +00:00
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
2026-03-26 09:29:55 +00:00
setOpen(false);
setQuery("");
}
};
2026-04-02 05:19:22 +00:00
const handleViewportChange = () => {
updateDesktopDropdownLayout();
};
2026-03-26 09:29:55 +00:00
document.addEventListener("mousedown", handlePointerDown);
2026-04-02 05:19:22 +00:00
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
2026-03-26 09:29:55 +00:00
return () => {
document.removeEventListener("mousedown", handlePointerDown);
2026-04-02 05:19:22 +00:00
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
2026-03-26 09:29:55 +00:00
};
}, [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>
2026-04-02 05:19:22 +00:00
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
2026-03-26 09:29:55 +00:00
<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"
}`}
>
2026-04-03 06:35:30 +00:00
<span>{getSearchableOptionLabel(item)}</span>
2026-03-26 09:29:55 +00:00
{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;
});
}}
2026-03-27 09:05:41 +00:00
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,
)}
2026-03-26 09:29:55 +00:00
>
<span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
2026-04-03 06:35:30 +00:00
{selectedOption ? getSearchableOptionLabel(selectedOption) : placeholder}
2026-03-26 09:29:55 +00:00
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
2026-04-03 06:35:30 +00:00
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[220]">
<div
ref={desktopDropdownRef}
style={{
position: "absolute",
top: desktopDropdownPlacement === "top"
? Math.max(24, desktopDropdownStyle.top - Math.min(desktopDropdownMaxHeight, 320))
: desktopDropdownStyle.top,
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
}}
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
className="overflow-y-auto overscroll-contain pr-1"
>
{renderSearchBody()}
</div>
</div>
</div>,
document.body,
)
: null}
2026-03-26 09:29:55 +00:00
<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-04-01 09:24:06 +00:00
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);
2026-04-03 06:35:30 +00:00
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
2026-04-01 09:24:06 +00:00
const isMobile = useIsMobileViewport();
2026-04-03 06:35:30 +00:00
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(320);
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ left: number; width: number; top: number } | null>(null);
2026-04-01 09:24:06 +00:00
const summary = value.length > 0 ? value.join("、") : placeholder;
useEffect(() => {
if (!open || isMobile) {
2026-04-03 06:35:30 +00:00
setDesktopDropdownStyle(null);
2026-04-01 09:24:06 +00:00
return;
}
2026-04-03 06:35:30 +00:00
const updateDesktopDropdownPosition = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const viewportHeight = window.innerHeight;
const safePadding = 24;
const panelChromeHeight = 132;
const availableBelow = Math.max(180, viewportHeight - rect.bottom - safePadding - panelChromeHeight);
const availableAbove = Math.max(180, rect.top - safePadding - panelChromeHeight);
const shouldOpenUpward = availableBelow < 300 && availableAbove > availableBelow;
const popupContentHeight = Math.min(360, shouldOpenUpward ? availableAbove : availableBelow);
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
setDesktopDropdownMaxHeight(popupContentHeight);
setDesktopDropdownStyle({
left: rect.left,
width: rect.width,
top: shouldOpenUpward
? Math.max(safePadding, rect.top - 8 - (popupContentHeight + panelChromeHeight))
: rect.bottom + 8,
});
};
updateDesktopDropdownPosition();
2026-04-01 09:24:06 +00:00
const handlePointerDown = (event: MouseEvent) => {
2026-04-03 06:35:30 +00:00
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
2026-04-01 09:24:06 +00:00
setOpen(false);
}
};
2026-04-03 06:35:30 +00:00
window.addEventListener("resize", updateDesktopDropdownPosition);
window.addEventListener("scroll", updateDesktopDropdownPosition, true);
2026-04-01 09:24:06 +00:00
document.addEventListener("mousedown", handlePointerDown);
return () => {
2026-04-03 06:35:30 +00:00
window.removeEventListener("resize", updateDesktopDropdownPosition);
window.removeEventListener("scroll", updateDesktopDropdownPosition, true);
2026-04-01 09:24:06 +00:00
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>
2026-04-03 06:35:30 +00:00
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[220]">
<div
ref={desktopDropdownRef}
style={{
position: "absolute",
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
top: desktopDropdownStyle.top,
}}
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div className="mb-3 shrink-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white"></p>
<p className="crm-field-note mt-1"></p>
</div>
<div style={{ maxHeight: `${desktopDropdownMaxHeight}px` }} className="overflow-y-auto pr-1">
{renderOptions()}
</div>
</div>
</div>,
document.body,
)
: null}
2026-04-01 09:24:06 +00:00
<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>
);
}
2026-03-19 06:23:03 +00:00
export default function Opportunities() {
2026-04-02 09:26:35 +00:00
const currentUserId = getStoredCurrentUserId();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
2026-03-27 09:05:41 +00:00
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
2026-03-26 09:29:55 +00:00
const [filter, setFilter] = useState("全部");
2026-03-27 09:05:41 +00:00
const [stageFilterOpen, setStageFilterOpen] = useState(false);
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-04-02 05:19:22 +00:00
const [exporting, setExporting] = useState(false);
2026-04-08 09:41:17 +00:00
const [exportFilterOpen, setExportFilterOpen] = useState(false);
const [exportFilters, setExportFilters] = useState<OpportunityExportFilters>({});
2026-03-20 08:39:07 +00:00
const [error, setError] = useState("");
2026-04-02 05:19:22 +00:00
const [exportError, setExportError] = useState("");
2026-03-20 08:39:07 +00:00
const [items, setItems] = useState<OpportunityItem[]>([]);
2026-03-26 09:29:55 +00:00
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
2026-04-01 09:24:06 +00:00
const [omsPreSalesOptions, setOmsPreSalesOptions] = useState<OmsPreSalesOption[]>([]);
2026-03-26 09:29:55 +00:00
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
2026-04-01 09:24:06 +00:00
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
2026-04-02 02:07:21 +00:00
const [opportunityTypeOptions, setOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
const [confidenceOptions, setConfidenceOptions] = useState<OpportunityDictOption[]>([]);
2026-03-20 08:39:07 +00:00
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
2026-04-01 09:24:06 +00:00
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("");
2026-03-27 09:05:41 +00:00
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
2026-03-26 09:29:55 +00:00
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
2026-04-08 09:41:17 +00:00
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen;
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));
2026-04-01 09:24:06 +00:00
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
2026-04-02 02:07:21 +00:00
setOpportunityTypeOptions((data.opportunityTypeOptions ?? []).filter((item) => item.value));
setConfidenceOptions((data.confidenceOptions ?? []).filter((item) => item.value));
2026-03-26 09:29:55 +00:00
}
} catch {
if (!cancelled) {
setStageOptions([]);
setOperatorOptions([]);
2026-04-01 09:24:06 +00:00
setProjectLocationOptions([]);
2026-04-02 02:07:21 +00:00
setOpportunityTypeOptions([]);
setConfidenceOptions([]);
2026-03-26 09:29:55 +00:00
}
}
}
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-04-02 02:07:21 +00:00
useEffect(() => {
if (!opportunityTypeOptions.length) {
return;
}
const defaultOpportunityType = opportunityTypeOptions[0]?.value || "";
setForm((current) => (current.opportunityType ? current : { ...current, opportunityType: defaultOpportunityType }));
}, [opportunityTypeOptions]);
useEffect(() => {
const effectiveConfidenceOptions = getEffectiveConfidenceOptions(confidenceOptions);
if (!effectiveConfidenceOptions.length) {
return;
}
const defaultConfidence = effectiveConfidenceOptions[0]?.value || "";
setForm((current) => ({
...current,
confidencePct: current.confidencePct
? normalizeConfidenceValue(current.confidencePct, effectiveConfidenceOptions)
: defaultConfidence,
}));
}, [confidenceOptions]);
2026-03-20 08:39:07 +00:00
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
2026-03-27 09:05:41 +00:00
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);
2026-04-01 09:24:06 +00:00
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 }]
: []
),
];
2026-04-02 02:07:21 +00:00
const normalizedOpportunityType = form.opportunityType?.trim() || "";
const effectiveOpportunityTypeOptions = opportunityTypeOptions.length > 0
? opportunityTypeOptions
: FALLBACK_OPPORTUNITY_TYPE_OPTIONS;
const effectiveConfidenceOptions = getEffectiveConfidenceOptions(confidenceOptions);
const buildEmptyForm = (): CreateOpportunityPayload => ({
...defaultForm,
confidencePct: effectiveConfidenceOptions[0]?.value || defaultForm.confidencePct,
});
2026-04-02 02:07:21 +00:00
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 }]
: []
),
];
2026-03-27 09:05:41 +00:00
const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
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 || "";
2026-04-01 09:24:06 +00:00
const selectedPreSalesName = selectedItem?.preSalesName || "无";
2026-04-02 09:26:35 +00:00
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const canPushSelectedItem = canEditSelectedItem;
2026-03-26 09:29:55 +00:00
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
2026-04-01 09:24:06 +00:00
const showCustomCompetitorInput = selectedCompetitors.includes("其他");
2026-03-26 09:29:55 +00:00
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 || ""],
}));
2026-04-01 09:24:06 +00:00
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 || ""],
})),
];
2026-03-26 09:29:55 +00:00
useEffect(() => {
if (selectedItem) {
setDetailTab("sales");
} else {
setPushConfirmOpen(false);
2026-04-01 09:24:06 +00:00
setPushPreSalesId(undefined);
setPushPreSalesName("");
2026-03-26 09:29:55 +00:00
}
}, [selectedItem]);
2026-03-19 06:23:03 +00:00
2026-03-27 09:05:41 +00:00
useEffect(() => {
if (selectedItem && !visibleItems.some((item) => item.id === selectedItem.id)) {
setSelectedItem(null);
}
}, [archiveTab, selectedItem, visibleItems]);
2026-04-01 09:24:06 +00:00
const getConfidenceColor = (score?: string | number | null) => {
const normalizedValue = normalizeConfidenceValue(score, effectiveConfidenceOptions);
const matchedIndex = effectiveConfidenceOptions.findIndex((item) => (item.value || "") === normalizedValue);
if (matchedIndex < 0) return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
if (matchedIndex === 0) 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 (matchedIndex === 1) 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-04-08 09:41:17 +00:00
const handleExport = async (filters: OpportunityExportFilters) => {
2026-04-02 05:19:22 +00:00
if (exporting) {
return;
}
setExporting(true);
setExportError("");
2026-04-08 09:41:17 +00:00
setExportFilters(filters);
2026-04-02 05:19:22 +00:00
try {
2026-04-08 09:41:17 +00:00
const overview = await getOpportunityOverview("", "全部");
const exportItems = (overview.items ?? [])
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
.filter((item) => matchesOpportunityExportFilters(item, filters, effectiveConfidenceOptions));
2026-04-08 09:41:17 +00:00
if (exportItems.length <= 0) {
2026-04-15 08:21:52 +00:00
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未签单" : "已签单"}商机`);
2026-04-08 09:41:17 +00:00
}
2026-04-02 05:19:22 +00:00
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("商机储备");
const headers = [
"项目编号",
"项目名称",
"项目地",
2026-04-02 09:26:35 +00:00
"最终用户",
2026-04-02 05:19:22 +00:00
"建设类型",
"运作方",
"项目阶段",
"项目把握度",
"预计金额(元)",
"预计下单时间",
"销售拓展人员姓名",
"销售拓展人员合作意向",
"销售拓展人员是否在职",
"拓展渠道名称",
"拓展渠道合作意向",
"拓展渠道建立联系时间",
"新华三负责人",
"售前",
"竞争对手",
"项目最新进展",
"后续规划",
"备注说明",
2026-04-15 08:46:20 +00:00
"创建人",
"更新修改时间",
"是否签单",
"是否推送OMS",
2026-04-02 05:19:22 +00:00
"跟进记录",
];
worksheet.addRow(headers);
2026-04-08 09:41:17 +00:00
exportItems.forEach((item) => {
2026-04-02 05:19:22 +00:00
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, effectiveConfidenceOptions),
2026-04-02 05:19:22 +00:00
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),
2026-04-15 08:46:20 +00:00
normalizeOpportunityExportText(item.owner),
normalizeOpportunityExportText(item.updatedAt),
formatOpportunityBoolean(item.archived, "已签单", "未签单"),
formatOpportunityBoolean(item.pushedToOms, "已推送", "未推送"),
2026-04-02 05:19:22 +00:00
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;
2026-04-02 09:26:35 +00:00
} else if (header.includes("项目名称") || header.includes("最终客户") || header.includes("最终用户")) {
2026-04-02 05:19:22 +00:00
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();
2026-04-15 08:21:52 +00:00
const filename = `商机储备_${archiveTab === "active" ? "未签单" : "已签单"}_${formatOpportunityExportFilenameTime()}.xlsx`;
2026-04-02 05:19:22 +00:00
downloadOpportunityExcelFile(filename, buffer);
2026-04-08 09:41:17 +00:00
setExportFilterOpen(false);
2026-04-02 05:19:22 +00:00
} catch (exportErr) {
setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
2026-03-20 08:39:07 +00:00
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
setForm((current) => ({ ...current, [key]: value }));
2026-03-27 09:05:41 +00:00
if (key in fieldErrors) {
setFieldErrors((current) => {
const next = { ...current };
delete next[key as OpportunityField];
return next;
});
}
2026-03-20 08:39:07 +00:00
};
const handleOpenCreate = () => {
setError("");
2026-03-27 09:05:41 +00:00
setFieldErrors({});
setForm(buildEmptyForm());
2026-04-01 09:24:06 +00:00
setSelectedCompetitors([]);
setCustomCompetitorName("");
2026-03-20 08:39:07 +00:00
setCreateOpen(true);
};
const resetCreateState = () => {
setCreateOpen(false);
setEditOpen(false);
setSubmitting(false);
setError("");
2026-03-27 09:05:41 +00:00
setFieldErrors({});
setForm(buildEmptyForm());
2026-04-01 09:24:06 +00:00
setSelectedCompetitors([]);
setCustomCompetitorName("");
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;
}
setError("");
2026-04-01 09:24:06 +00:00
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
2026-03-27 09:05:41 +00:00
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
setError("请先完整填写商机必填字段");
return;
}
2026-03-20 08:39:07 +00:00
2026-03-27 09:05:41 +00:00
setSubmitting(true);
2026-03-26 09:29:55 +00:00
2026-03-27 09:05:41 +00:00
try {
2026-04-01 09:24:06 +00:00
await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, 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-04-02 09:26:35 +00:00
if (!canEditSelectedItem) {
setError("仅可编辑本人负责的商机");
return;
}
2026-03-20 08:39:07 +00:00
setError("");
2026-03-27 09:05:41 +00:00
setFieldErrors({});
setForm(toFormFromItem(selectedItem, effectiveConfidenceOptions));
2026-04-01 09:24:06 +00:00
const competitorState = parseCompetitorState(selectedItem.competitorName);
setSelectedCompetitors(competitorState.selections);
setCustomCompetitorName(competitorState.customName);
2026-03-20 08:39:07 +00:00
setEditOpen(true);
};
const handleEditSubmit = async () => {
if (!selectedItem || submitting) {
return;
}
2026-04-02 09:26:35 +00:00
if (!canEditSelectedItem) {
setError("仅可编辑本人负责的商机");
return;
}
2026-03-20 08:39:07 +00:00
setError("");
2026-04-01 09:24:06 +00:00
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
2026-03-27 09:05:41 +00:00
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
setError("请先完整填写商机必填字段");
return;
}
2026-03-20 08:39:07 +00:00
2026-03-27 09:05:41 +00:00
setSubmitting(true);
2026-03-26 09:29:55 +00:00
2026-03-27 09:05:41 +00:00
try {
2026-04-01 09:24:06 +00:00
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, 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 () => {
2026-04-02 09:26:35 +00:00
if (!selectedItem || pushingOms) {
return;
}
if (!canPushSelectedItem) {
setError("仅可推送本人负责的商机");
2026-03-20 08:39:07 +00:00
return;
}
2026-03-26 09:29:55 +00:00
setPushingOms(true);
setError("");
try {
2026-04-01 09:24:06 +00:00
const payload: PushOpportunityToOmsPayload = {
preSalesId: pushPreSalesId,
preSalesName: pushPreSalesName.trim() || undefined,
};
await pushOpportunityToOms(selectedItem.id, payload);
2026-03-26 09:29:55 +00:00
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-04-01 09:24:06 +00:00
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 () => {
2026-04-02 09:26:35 +00:00
if (!selectedItem || pushingOms) {
return;
}
if (!canPushSelectedItem) {
setError("仅可推送本人负责的商机");
2026-03-20 08:39:07 +00:00
return;
}
2026-04-01 09:24:06 +00:00
setError("");
syncPushPreSalesSelection(selectedItem, omsPreSalesOptions);
2026-03-26 09:29:55 +00:00
setPushConfirmOpen(true);
2026-04-01 09:24:06 +00:00
setLoadingOmsPreSales(true);
try {
const data = await getOpportunityOmsPreSalesOptions();
setOmsPreSalesOptions(data);
syncPushPreSalesSelection(selectedItem, data);
} catch (loadError) {
setOmsPreSalesOptions([]);
setError(loadError instanceof Error ? loadError.message : "加载售前人员失败");
} finally {
setLoadingOmsPreSales(false);
}
2026-03-26 09:29:55 +00:00
};
2026-03-20 08:39:07 +00:00
2026-03-26 09:29:55 +00:00
const handleConfirmPushToOms = async () => {
2026-04-02 09:26:35 +00:00
if (!canPushSelectedItem) {
setError("仅可推送本人负责的商机");
return;
}
2026-04-01 09:24:06 +00:00
if (!pushPreSalesId && !pushPreSalesName.trim()) {
setError("请选择售前人员");
return;
}
2026-03-26 09:29:55 +00:00
setPushConfirmOpen(false);
await handlePushToOms();
2026-03-20 08:39:07 +00:00
};
const renderEmpty = () => (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-04-15 08:21:52 +00:00
{archiveTab === "active" ? "暂无未签单商机,先新增一条试试。" : "暂无已签单商机。"}
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">
2026-03-27 09:05:41 +00:00
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
</div>
2026-04-02 05:19:22 +00:00
<div className="flex items-center gap-2">
<button
2026-04-08 09:41:17 +00:00
onClick={() => {
setExportError("");
setExportFilterOpen(true);
}}
2026-04-02 05:19:22 +00:00
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>
2026-03-19 06:23:03 +00:00
</header>
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
2026-03-27 09:05:41 +00:00
<button
2026-04-02 05:19:22 +00:00
onClick={() => {
setArchiveTab("active");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
2026-03-27 09:05:41 +00:00
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"
}`}
>
2026-04-15 08:21:52 +00:00
2026-03-27 09:05:41 +00:00
</button>
<button
2026-04-02 05:19:22 +00:00
onClick={() => {
setArchiveTab("archived");
setExportError("");
}}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
2026-03-27 09:05:41 +00:00
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"
}`}
>
2026-04-15 08:21:52 +00:00
2026-03-27 09:05:41 +00:00
</button>
2026-03-19 06:23:03 +00:00
</div>
2026-03-27 09:05:41 +00:00
<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"
2026-04-02 09:26:35 +00:00
placeholder="搜索项目名称、最终用户、编码..."
2026-03-27 09:05:41 +00:00
value={keyword}
2026-04-02 05:19:22 +00:00
onChange={(event) => {
setKeyword(event.target.value);
setExportError("");
}}
2026-03-27 09:05:41 +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"
/>
</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>
2026-03-19 06:23:03 +00:00
</div>
2026-04-02 05:19:22 +00:00
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
2026-03-26 09:29:55 +00:00
<div className="crm-list-stack">
2026-03-27 09:05:41 +00:00
{visibleItems.length > 0 ? (
2026-04-02 09:26:35 +00:00
visibleItems.map((opp, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId;
return (
<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={cn(
"crm-card crm-card-pad group relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
2026-04-03 02:11:19 +00:00
<div className="flex items-start gap-3">
2026-04-02 09:26:35 +00:00
<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>
2026-04-03 02:11:19 +00:00
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className={getConfidenceBadgeClass(opp.confidence, effectiveConfidenceOptions)}>
{getConfidenceLabel(opp.confidence, effectiveConfidenceOptions)}
2026-04-03 02:11:19 +00:00
</span>
<span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"}
</span>
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
opp.pushedToOms
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
</span>
</div>
2026-04-02 09:26:35 +00:00
</div>
2026-03-19 06:23:03 +00:00
</div>
2026-04-02 09:26:35 +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>
</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>
2026-04-03 02:11:19 +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.owner || "无"}</span>
</div>
2026-03-20 08:39:07 +00:00
</div>
2026-04-02 09:26:35 +00:00
</motion.div>
);
})
2026-03-20 08:39:07 +00:00
) : renderEmpty()}
</div>
2026-03-27 09:05:41 +00:00
<AnimatePresence>
2026-04-08 09:41:17 +00:00
{exportFilterOpen ? (
<OpportunityExportFilterModal
initialFilters={exportFilters}
exporting={exporting}
exportError={exportError}
archiveTab={archiveTab}
stageOptions={stageOptions}
confidenceOptions={effectiveConfidenceOptions}
2026-04-08 09:41:17 +00:00
projectLocationOptions={projectLocationOptions}
opportunityTypeOptions={opportunityTypeOptions}
operatorOptions={operatorOptions}
onClose={() => setExportFilterOpen(false)}
onConfirm={(filters) => void handleExport(filters)}
/>
) : null}
2026-03-27 09:05:41 +00:00
{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);
2026-04-02 05:19:22 +00:00
setExportError("");
2026-03-27 09:05:41 +00:00
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>
2026-03-20 08:39:07 +00:00
<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-27 09:05:41 +00:00
<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>
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">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-04-01 09:24:06 +00:00
<AdaptiveSelect
value={form.projectLocation || ""}
placeholder="请选择"
sheetTitle="项目地"
searchable
searchPlaceholder="搜索项目地"
options={projectLocationSelectOptions}
className={getFieldInputClass(Boolean(fieldErrors.projectLocation))}
onChange={(value) => handleChange("projectLocation", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
2026-03-26 09:29:55 +00:00
</label>
2026-03-20 08:39:07 +00:00
<label className="space-y-2 sm:col-span-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2 sm:col-span-2">
2026-04-02 09:26:35 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.operatorName || ""}
placeholder="请选择"
sheetTitle="运作方"
options={[
{ value: "", label: "请选择" },
...operatorOptions.map((item) => ({
value: item.value || "",
label: item.label || item.value || "无",
})),
]}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => handleChange("operatorName", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.operatorName ? <p className="text-xs text-rose-500">{fieldErrors.operatorName}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
2026-03-26 09:29:55 +00:00
{showSalesExpansionField ? (
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<SearchableSelect
value={form.salesExpansionId}
options={salesExpansionSearchOptions}
placeholder="请选择新华三负责人"
searchPlaceholder="搜索姓名、工号、办事处、电话"
emptyText="未找到匹配的销售拓展人员"
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => handleChange("salesExpansionId", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
2026-03-26 09:29:55 +00:00
</label>
) : null}
{showChannelExpansionField ? (
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<SearchableSelect
value={form.channelExpansionId}
options={channelExpansionSearchOptions}
placeholder="请选择渠道名称"
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
emptyText="未找到匹配的渠道"
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => handleChange("channelExpansionId", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
2026-03-26 09:29:55 +00:00
</label>
) : null}
2026-03-20 08:39:07 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={normalizeConfidenceValue(form.confidencePct, effectiveConfidenceOptions)}
2026-03-26 09:29:55 +00:00
placeholder="请选择"
sheetTitle="项目把握度"
options={effectiveConfidenceOptions.map((item) => ({
value: item.value || "",
label: item.label || item.value || "",
}))}
2026-03-27 09:05:41 +00:00
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", normalizeConfidenceValue(value, effectiveConfidenceOptions))}
2026-03-26 09:29:55 +00:00
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.stage}
placeholder="请选择"
sheetTitle="项目阶段"
options={stageOptions.map((item) => ({
value: item.value || "",
label: item.label || item.value || "无",
}))}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => handleChange("stage", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.stage ? <p className="text-xs text-rose-500">{fieldErrors.stage}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-04-01 09:24:06 +00:00
<CompetitorMultiSelect
value={selectedCompetitors}
options={COMPETITOR_OPTIONS}
placeholder="请选择竞争对手"
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-04-01 09:24:06 +00:00
onChange={(nextValue) => {
2026-03-27 09:05:41 +00:00
setFieldErrors((current) => {
if (!current.competitorName) {
return current;
}
const next = { ...current };
delete next.competitorName;
return next;
});
2026-04-01 09:24:06 +00:00
setSelectedCompetitors(nextValue);
if (!nextValue.includes("其他")) {
setCustomCompetitorName("");
2026-03-26 09:29:55 +00:00
}
}}
/>
2026-04-01 09:24:06 +00:00
{fieldErrors.competitorName && !showCustomCompetitorInput ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
2026-04-01 09:24:06 +00:00
{showCustomCompetitorInput ? (
2026-03-26 09:29:55 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<input
2026-04-01 09:24:06 +00:00
value={customCompetitorName}
onChange={(e) => setCustomCompetitorName(e.target.value)}
placeholder="请输入其他竞争对手"
2026-03-27 09:05:41 +00:00
className={getFieldInputClass(Boolean(fieldErrors.competitorName))}
2026-03-26 09:29:55 +00:00
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.competitorName ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
2026-03-26 09:29:55 +00:00
</label>
) : null}
2026-03-20 08:39:07 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.opportunityType}
2026-04-02 02:07:21 +00:00
placeholder="请选择"
2026-03-27 09:05:41 +00:00
sheetTitle="建设类型"
2026-04-02 02:07:21 +00:00
options={opportunityTypeSelectOptions}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => handleChange("opportunityType", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null}
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-27 09:05:41 +00:00
{error ? <div className="crm-alert crm-alert-error mt-4">{error}</div> : null}
2026-03-20 08:39:07 +00:00
</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">
2026-03-27 09:05:41 +00:00
<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" />
2026-03-26 09:29:55 +00:00
</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">
2026-04-01 09:24:06 +00:00
OMS OMS
2026-03-26 09:29:55 +00:00
</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>
2026-04-01 09:24:06 +00:00
<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}
2026-03-26 09:29:55 +00:00
</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)}
2026-03-27 09:05:41 +00:00
className="crm-btn crm-btn-secondary min-h-0 rounded-2xl px-4 py-3"
2026-03-26 09:29:55 +00:00
>
</button>
<button
type="button"
onClick={() => void handleConfirmPushToOms()}
disabled={pushingOms}
2026-03-27 09:05:41 +00:00
className="crm-btn crm-btn-primary min-h-0 rounded-2xl px-4 py-3 disabled:cursor-not-allowed disabled:opacity-60"
2026-03-26 09:29:55 +00:00
>
{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 }}
className={`fixed inset-x-0 bottom-0 z-50 flex h-[90dvh] w-full flex-col overflow-hidden 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>
2026-03-27 09:05:41 +00:00
<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" />
2026-03-19 06:23:03 +00:00
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
2026-03-26 09:29:55 +00:00
<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-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)}`}> {getConfidenceLabel(selectedItem.confidence, effectiveConfidenceOptions)}</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-27 09:05:41 +00:00
<FileText className="crm-icon-md text-violet-500" />
2026-03-19 06:23:03 +00:00
</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 || "无"} />
2026-04-02 09:26:35 +00:00
<DetailItem label="最终用户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
2026-03-26 09:29:55 +00:00
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
2026-04-01 09:24:06 +00:00
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
2026-03-27 09:05:41 +00:00
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
2026-03-26 09:29:55 +00:00
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence, effectiveConfidenceOptions)} />
2026-03-26 09:29:55 +00:00
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
2026-04-02 09:26:35 +00:00
<DetailItem
label="是否已推送OMS"
value={(
<span
className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold",
selectedItem.pushedToOms
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
)}
>
{selectedItem.pushedToOms ? "已推送" : "未推送"}
</span>
)}
/>
2026-03-26 09:29:55 +00:00
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
2026-03-27 09:05:41 +00:00
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
2026-03-26 09:29:55 +00:00
<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>
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</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>
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</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">
2026-03-27 09:05:41 +00:00
<Clock className="crm-icon-md text-violet-500" />
2026-03-26 09:29:55 +00:00
</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
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</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="w-full shrink-0 border-t border-slate-200/80 bg-slate-50/95 backdrop-blur dark:border-slate-800/80 dark:bg-slate-900/90">
<div className="px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:p-4">
{error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
{!canEditSelectedItem ? (
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500"></p>
) : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleOpenEdit}
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
</button>
<button
type="button"
onClick={() => void handleOpenPushConfirm()}
disabled={!canPushSelectedItem || pushingOms}
title={
!canPushSelectedItem
? "仅本人可操作"
: selectedItem.pushedToOms
? "重新推送 OMS"
: "推送 OMS"
}
className={cn(
"crm-btn inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60",
!canPushSelectedItem
? "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",
)}
>
{!canPushSelectedItem ? "仅本人可操作" : pushingOms ? "推送中..." : selectedItem.pushedToOms ? "重新推送 OMS" : "推送 OMS"}
</button>
</div>
2026-03-19 06:23:03 +00:00
</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 : "无";
}