import { useEffect, useState, type ReactNode } from "react";
import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
import {
createChannelExpansion,
createSalesExpansion,
getExpansionMeta,
getExpansionOverview,
updateChannelExpansion,
updateSalesExpansion,
type ChannelExpansionContact,
type ChannelExpansionItem,
type CreateChannelExpansionPayload,
type CreateSalesExpansionPayload,
type ExpansionDictOption,
type ExpansionFollowUp,
type SalesExpansionItem,
} from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel";
const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 transition-all hover:border-violet-200 hover:bg-violet-100 dark:border-violet-500/20 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/15";
function createEmptyChannelContact(): ChannelExpansionContact {
return {
name: "",
mobile: "",
title: "",
};
}
const defaultSalesForm: CreateSalesExpansionPayload = {
employeeNo: "",
candidateName: "",
officeName: "",
mobile: "",
industry: "",
title: "",
intentLevel: "medium",
hasDesktopExp: false,
employmentStatus: "active",
};
const defaultChannelForm: CreateChannelExpansionPayload = {
channelCode: "",
channelName: "",
province: "",
officeAddress: "",
channelIndustry: "",
contactEstablishedDate: "",
intentLevel: "medium",
hasDesktopExp: false,
channelAttribute: "",
internalAttribute: "",
stage: "initial_contact",
remark: "",
contacts: [createEmptyChannelContact()],
};
function ModalShell({
title,
subtitle,
onClose,
children,
footer,
}: {
title: string;
subtitle: string;
onClose: () => void;
children: ReactNode;
footer: ReactNode;
}) {
return (
<>
>
);
}
function DetailItem({
label,
value,
icon,
className = "",
}: {
label: string;
value: ReactNode;
icon?: ReactNode;
className?: string;
}) {
return (
);
}
export default function Expansion() {
const location = useLocation();
const [activeTab, setActiveTab] = useState("sales");
const [selectedItem, setSelectedItem] = useState(null);
const [keyword, setKeyword] = useState("");
const [salesData, setSalesData] = useState([]);
const [channelData, setChannelData] = useState([]);
const [officeOptions, setOfficeOptions] = useState([]);
const [industryOptions, setIndustryOptions] = useState([]);
const [channelAttributeOptions, setChannelAttributeOptions] = useState([]);
const [internalAttributeOptions, setInternalAttributeOptions] = useState([]);
const [nextChannelCode, setNextChannelCode] = useState("");
const [refreshTick, setRefreshTick] = useState(0);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [createError, setCreateError] = useState("");
const [editError, setEditError] = useState("");
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
const [salesForm, setSalesForm] = useState(defaultSalesForm);
const [channelForm, setChannelForm] = useState(defaultChannelForm);
const [editSalesForm, setEditSalesForm] = useState(defaultSalesForm);
const [editChannelForm, setEditChannelForm] = useState(defaultChannelForm);
const hasForegroundModal = createOpen || editOpen;
useEffect(() => {
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
if (requestedTab === "sales" || requestedTab === "channel") {
setActiveTab(requestedTab);
}
}, [location.state]);
useEffect(() => {
let cancelled = false;
async function loadMeta() {
try {
const data = await getExpansionMeta();
if (!cancelled) {
setOfficeOptions(data.officeOptions ?? []);
setIndustryOptions(data.industryOptions ?? []);
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
setNextChannelCode(data.nextChannelCode ?? "");
}
} catch {
if (!cancelled) {
setOfficeOptions([]);
setIndustryOptions([]);
setChannelAttributeOptions([]);
setInternalAttributeOptions([]);
setNextChannelCode("");
}
}
}
void loadMeta();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
async function loadExpansionData() {
try {
const data = await getExpansionOverview(keyword);
if (cancelled) {
return;
}
setSalesData(data.salesItems ?? []);
setChannelData(data.channelItems ?? []);
setSelectedItem(null);
} catch {
if (!cancelled) {
setSalesData([]);
setChannelData([]);
setSelectedItem(null);
}
}
}
void loadExpansionData();
return () => {
cancelled = true;
};
}, [keyword, refreshTick]);
const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? [];
useEffect(() => {
if (selectedItem?.type === "sales") {
setSalesDetailTab("projects");
} else if (selectedItem?.type === "channel") {
setChannelDetailTab("projects");
}
}, [selectedItem]);
const handleSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => {
setSalesForm((current) => ({ ...current, [key]: value }));
};
const handleChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => {
setChannelForm((current) => ({ ...current, [key]: value }));
};
const handleEditSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => {
setEditSalesForm((current) => ({ ...current, [key]: value }));
};
const handleEditChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => {
setEditChannelForm((current) => ({ ...current, [key]: value }));
};
const handleChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string, isEdit = false) => {
const setter = isEdit ? setEditChannelForm : setChannelForm;
setter((current) => {
const nextContacts = [...(current.contacts ?? [])];
const target = { ...(nextContacts[index] ?? createEmptyChannelContact()), [key]: value };
nextContacts[index] = target;
return { ...current, contacts: nextContacts };
});
};
const addChannelContact = (isEdit = false) => {
const setter = isEdit ? setEditChannelForm : setChannelForm;
setter((current) => ({
...current,
contacts: [...(current.contacts ?? []), createEmptyChannelContact()],
}));
};
const removeChannelContact = (index: number, isEdit = false) => {
const setter = isEdit ? setEditChannelForm : setChannelForm;
setter((current) => {
const currentContacts = current.contacts ?? [];
const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index);
return {
...current,
contacts: nextContacts.length > 0 ? nextContacts : [createEmptyChannelContact()],
};
});
};
const resetCreateState = () => {
setCreateOpen(false);
setCreateError("");
setSalesForm(defaultSalesForm);
setChannelForm(defaultChannelForm);
};
const resetEditState = () => {
setEditOpen(false);
setEditError("");
setEditSalesForm(defaultSalesForm);
setEditChannelForm(defaultChannelForm);
};
const handleOpenCreate = () => {
setCreateError("");
setCreateOpen(true);
};
const handleOpenEdit = () => {
if (!selectedItem) {
return;
}
setEditError("");
if (selectedItem.type === "sales") {
setEditSalesForm({
employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "",
candidateName: selectedItem.name ?? "",
officeName: selectedItem.officeCode ?? "",
mobile: selectedItem.phone === "无" ? "" : selectedItem.phone ?? "",
targetDept: selectedItem.dept === "无" ? "" : selectedItem.dept ?? selectedItem.targetDept ?? "",
industry: selectedItem.industryCode ?? "",
title: selectedItem.title === "无" ? "" : selectedItem.title ?? "",
intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasExp),
employmentStatus: selectedItem.active ? "active" : "left",
});
} else {
setEditChannelForm({
channelCode: selectedItem.channelCode ?? "",
channelName: selectedItem.name ?? "",
province: selectedItem.province === "无" ? "" : selectedItem.province ?? "",
officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "",
channelIndustry: selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry ?? "",
annualRevenue: selectedItem.annualRevenue ? Number(selectedItem.annualRevenue) : undefined,
staffSize: selectedItem.size ?? undefined,
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasDesktopExp),
channelAttribute: selectedItem.channelAttribute === "无" ? "" : selectedItem.channelAttribute ?? "",
internalAttribute: selectedItem.internalAttribute === "无" ? "" : selectedItem.internalAttribute ?? "",
stage: selectedItem.stageCode ?? "initial_contact",
remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "",
contacts: (selectedItem.contacts?.length ?? 0) > 0
? selectedItem.contacts?.map((contact) => ({
name: contact.name === "无" ? "" : contact.name ?? "",
mobile: contact.mobile === "无" ? "" : contact.mobile ?? "",
title: contact.title === "无" ? "" : contact.title ?? "",
}))
: [createEmptyChannelContact()],
});
}
setEditOpen(true);
};
const handleCreateSubmit = async () => {
if (submitting) {
return;
}
setSubmitting(true);
setCreateError("");
try {
if (activeTab === "sales") {
await createSalesExpansion({
...salesForm,
targetDept: salesForm.targetDept?.trim() || undefined,
});
} else {
await createChannelExpansion({
...channelForm,
annualRevenue: channelForm.annualRevenue || undefined,
staffSize: channelForm.staffSize || undefined,
});
}
resetCreateState();
setRefreshTick((current) => current + 1);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "新增失败");
} finally {
setSubmitting(false);
}
};
const handleEditSubmit = async () => {
if (!selectedItem || submitting) {
return;
}
setSubmitting(true);
setEditError("");
try {
if (selectedItem.type === "sales") {
await updateSalesExpansion(selectedItem.id, {
...editSalesForm,
targetDept: editSalesForm.targetDept?.trim() || undefined,
});
} else {
await updateChannelExpansion(selectedItem.id, {
...editChannelForm,
annualRevenue: editChannelForm.annualRevenue || undefined,
staffSize: editChannelForm.staffSize || undefined,
});
}
resetEditState();
setSelectedItem(null);
setRefreshTick((current) => current + 1);
} catch (error) {
setEditError(error instanceof Error ? error.message : "编辑失败");
} finally {
setSubmitting(false);
}
};
const renderEmpty = () => (
暂无
);
const renderFollowUpTimeline = () => {
if (followUpRecords.length <= 0) {
return (
暂无跟进记录
);
}
return (
{followUpRecords.map((record) => {
const summary = getExpansionFollowUpSummary(record);
return (
拜访时间
{summary.visitStartTime}
沟通内容
{summary.evaluationContent}
跟进人: {record.user || "无"}{record.date || "无"}
)})}
);
};
const handleTabChange = (tab: ExpansionTab) => {
setActiveTab(tab);
setSelectedItem(null);
};
const renderSalesForm = (
form: CreateSalesExpansionPayload,
onChange: (key: K, value: CreateSalesExpansionPayload[K]) => void,
) => (
);
const renderChannelForm = (
form: CreateChannelExpansionPayload,
onChange: (key: K, value: CreateChannelExpansionPayload[K]) => void,
isEdit = false,
) => (
人员信息
{(form.contacts ?? []).map((contact, index) => (
handleChannelContactChange(index, "name", e.target.value, isEdit)} placeholder="人员姓名" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
handleChannelContactChange(index, "mobile", e.target.value, isEdit)} placeholder="联系电话" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
handleChannelContactChange(index, "title", e.target.value, isEdit)} placeholder="职位" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
))}
);
return (
setKeyword(event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pl-10 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"
/>
{activeTab === "sales" ? (
salesData.length > 0 ? (
salesData.map((item, i) => (
setSelectedItem(item)}
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
>
{item.name || "无"}
{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}
{item.active ? "在职" : "离职"}
意向:
{item.intent || "无"}
跟进项目金额:
{formatRelatedProjectAmount(item.relatedProjects)}
))
) : renderEmpty()
) : channelData.length > 0 ? (
channelData.map((item, i) => (
setSelectedItem(item)}
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
>
{item.name || "无"}
{item.province || "无"} · {item.internalAttribute || "无"}
{item.intent ? `${item.intent}意向` : "未评估"}
建立联系:
{item.establishedDate || "无"}
跟进项目金额:
{formatRelatedProjectAmount(item.relatedProjects)}
))
) : renderEmpty()}
{createOpen && (
)}
>
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange) : renderChannelForm(channelForm, handleChannelChange)}
{createError ? {createError}
: null}
)}
{editOpen && selectedItem && (
)}
>
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange) : renderChannelForm(editChannelForm, handleEditChannelChange, true)}
{editError ? {editError}
: null}
)}
{selectedItem && (
<>
setSelectedItem(null)}
className={`fixed inset-0 z-40 bg-slate-900/20 backdrop-blur-sm transition-opacity dark:bg-slate-900/60 ${
hasForegroundModal ? "pointer-events-none opacity-30" : ""
}`}
/>
{selectedItem.type === "sales" ? "销售拓展详情" : "渠道拓展详情"}
{selectedItem.type === "sales" ? (
工号 {selectedItem.employeeNo || "无"}
) : (
{selectedItem.channelCode || "未编码"}
)}
{selectedItem.name || "无"}
{selectedItem.type === "sales"
? `${selectedItem.officeName || "无"} · ${selectedItem.dept || "无"} · ${selectedItem.title || "无"}`
: `${selectedItem.province || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.officeAddress || "无"}`}
{selectedItem.type === "sales" ? (
{selectedItem.active ? "在职" : "离职"}
) : null}
基本信息
{selectedItem.type === "sales" ? (
<>
} />
} />
>
) : (
<>
} />
} />
>
)}
{selectedItem.type !== "sales" ? : null}
{selectedItem.type === "sales" ? (
{salesDetailTab === "projects" ? (
{(selectedItem.relatedProjects?.length ?? 0) > 0 ? (
{selectedItem.relatedProjects?.map((project) => (
))}
) : (
暂无关联项目
)}
) : (
跟进记录
{renderFollowUpTimeline()}
)}
) : (
{channelDetailTab === "projects" ? (
(selectedItem.relatedProjects?.length ?? 0) > 0 ? (
{selectedItem.relatedProjects?.map((project) => (
))}
) : (
暂无关联项目
)
) : null}
{channelDetailTab === "contacts" ? (
(selectedItem.contacts?.length ?? 0) > 0 ? (
{selectedItem.contacts?.map((contact, index) => (
人员姓名
{contact.name || "无"}
联系电话
{contact.mobile || "无"}
))}
) : (
暂无人员信息
)
) : null}
{channelDetailTab === "followups" ? (
renderFollowUpTimeline()
) : null}
)}
>
)}
);
}
function formatAmount(value: number) {
return `¥${new Intl.NumberFormat("zh-CN").format(value)}`;
}
function formatRelatedProjectAmount(projects?: Array<{ amount?: number }>) {
if (!projects || projects.length === 0) {
return "无";
}
const totalAmount = projects.reduce((sum, project) => sum + Number(project.amount || 0), 0);
return formatAmount(totalAmount);
}
function getExpansionFollowUpSummary(record: {
type?: string;
date?: string;
content?: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
}) {
const content = record.content || "";
const parsedVisit = extractFollowUpField(content, "拜访时间");
const parsedEvaluation = extractFollowUpField(content, "沟通内容");
const parsedPlan = extractFollowUpField(content, "后续规划");
const fallbackVisitStartTime = record.type === "工作日报" && record.date
? record.date.slice(0, 10)
: undefined;
return {
visitStartTime: normalizeFollowUpDisplayValue(formatFollowUpDateValue(
pickFollowUpValue(record.visitStartTime, parsedVisit, fallbackVisitStartTime),
)),
evaluationContent: normalizeFollowUpDisplayValue(
pickFollowUpValue(record.evaluationContent, parsedEvaluation),
),
nextPlan: normalizeFollowUpDisplayValue(
pickFollowUpValue(record.nextPlan, parsedPlan),
),
};
}
function pickFollowUpValue(...values: Array) {
return values.find((value) => {
const normalized = value?.trim();
return normalized && normalized !== "无";
});
}
function formatFollowUpDateValue(value?: string) {
const normalized = value?.trim();
if (!normalized) {
return undefined;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : normalized;
}
function extractFollowUpField(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 normalizeFollowUpDisplayValue(value?: string) {
const normalized = value?.trim();
return normalized ? normalized : "无";
}