import { useCallback, useEffect, useState, type ReactNode } from "react"; import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { useLocation } from "react-router-dom"; import { checkChannelExpansionDuplicate, checkSalesExpansionDuplicate, createChannelExpansion, createSalesExpansion, decodeExpansionMultiValue, getExpansionCityOptions, getExpansionMeta, getExpansionOverview, getStoredCurrentUserId, updateChannelExpansion, updateSalesExpansion, type ChannelExpansionContact, type ChannelExpansionItem, type CreateChannelExpansionPayload, type CreateSalesExpansionPayload, type ExpansionDictOption, type ExpansionFollowUp, type SalesExpansionItem, } from "@/lib/auth"; import { AdaptiveSelect } from "@/components/AdaptiveSelect"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import { cn } from "@/lib/utils"; type ExpansionItem = SalesExpansionItem | ChannelExpansionItem; type ExpansionTab = "sales" | "channel"; type SalesCreateField = | "employeeNo" | "officeName" | "candidateName" | "mobile" | "targetDept" | "industry" | "title" | "intentLevel" | "employmentStatus"; type ChannelField = | "channelName" | "province" | "city" | "officeAddress" | "channelIndustry" | "certificationLevel" | "annualRevenue" | "staffSize" | "contactEstablishedDate" | "intentLevel" | "channelAttribute" | "channelAttributeCustom" | "internalAttribute" | "contacts"; 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: "", city: "", officeAddress: "", channelIndustry: [], certificationLevel: "", contactEstablishedDate: "", intentLevel: "medium", hasDesktopExp: false, channelAttribute: [], channelAttributeCustom: "", internalAttribute: [], stage: "initial_contact", remark: "", contacts: [createEmptyChannelContact()], }; function isOtherOption(option?: ExpansionDictOption) { const candidate = `${option?.label ?? ""}${option?.value ?? ""}`.toLowerCase(); return candidate.includes("其他") || candidate.includes("其它") || candidate.includes("other"); } function normalizeOptionalText(value?: string) { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } function dedupeExpansionItemsById(items: T[]) { const seenIds = new Set(); return items.filter((item) => { if (item.id === null || item.id === undefined) { return true; } if (seenIds.has(item.id)) { return false; } seenIds.add(item.id); return true; }); } 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 normalizeExportText(value?: string | number | boolean | null) { if (value === null || value === undefined) { return ""; } const normalized = String(value).replace(/\r?\n/g, " ").trim(); if (!normalized || normalized === "无") { return ""; } return normalized; } function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") { if (value === null || value === undefined) { return ""; } return value ? trueLabel : falseLabel; } function formatExportFollowUps(followUps?: ExpansionFollowUp[]) { if (!followUps?.length) { return ""; } return followUps .map((followUp) => { const summary = getExpansionFollowUpSummary(followUp); const lines = [ [normalizeExportText(followUp.date), normalizeExportText(followUp.type)].filter(Boolean).join(" "), normalizeExportText(summary.visitStartTime) ? `拜访时间:${normalizeExportText(summary.visitStartTime)}` : "", normalizeExportText(summary.evaluationContent) ? `沟通内容:${normalizeExportText(summary.evaluationContent)}` : "", normalizeExportText(summary.nextPlan) ? `后续规划:${normalizeExportText(summary.nextPlan)}` : "", ].filter(Boolean); return lines.join("\n"); }) .filter(Boolean) .join("\n\n"); } function formatExportFilenameTime(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); return `${year}${month}${day}_${hours}${minutes}${seconds}`; } function downloadExcelFile(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 buildSalesExportHeaders(items: SalesExpansionItem[]) { const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0)); const headers = [ "工号", "姓名", "创建人", "联系方式", "代表处 / 办事处", "所属部门", "职务", "所属行业", "合作意向", "销售是否在职", "销售以前是否做过云桌面项目", "跟进项目金额", ]; for (let index = 0; index < maxProjects; index += 1) { headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`); } headers.push("跟进记录"); return headers; } function buildSalesExportData(items: SalesExpansionItem[]) { const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0)); return items.map((item) => { const row = [ normalizeExportText(item.employeeNo), normalizeExportText(item.name), normalizeExportText(item.owner), normalizeExportText(item.phone), normalizeExportText(item.officeName), normalizeExportText(item.dept), normalizeExportText(item.title), normalizeExportText(item.industry), normalizeExportText(item.intent), item.active === null || item.active === undefined ? "" : item.active ? "是" : "否", formatExportBoolean(item.hasExp), normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)), ]; for (let index = 0; index < maxProjects; index += 1) { const project = item.relatedProjects?.[index]; row.push( normalizeExportText(project?.opportunityCode), normalizeExportText(project?.opportunityName), project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)), ); } row.push(formatExportFollowUps(item.followUps)); return row; }); } function buildChannelExportHeaders(items: ChannelExpansionItem[]) { const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0)); const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0)); const headers = [ "编码", "渠道名称", "创建人", "省份", "市", "办公地址", "认证级别", "聚焦行业", "渠道属性", "新华三内部属性", "合作意向", "建立联系时间", "营收规模", "人员规模", "以前是否做过云桌面项目", "跟进项目金额", ]; for (let index = 0; index < maxProjects; index += 1) { headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`); } for (let index = 0; index < maxContacts; index += 1) { headers.push(`人员${index + 1}姓名`, `人员${index + 1}联系电话`, `人员${index + 1}职位`); } headers.push("备注说明"); headers.push("跟进记录"); return headers; } function buildChannelExportData(items: ChannelExpansionItem[]) { const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0)); const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0)); return items.map((item) => { const row = [ normalizeExportText(item.channelCode), normalizeExportText(item.name), normalizeExportText(item.owner), normalizeExportText(item.province), normalizeExportText(item.city), normalizeExportText(item.officeAddress), normalizeExportText(item.certificationLevel), normalizeExportText(item.channelIndustry), normalizeExportText(item.channelAttribute), normalizeExportText(item.internalAttribute), normalizeExportText(item.intent), normalizeExportText(item.establishedDate), normalizeExportText(item.revenue), item.size ? `${item.size}人` : "", formatExportBoolean(item.hasDesktopExp), normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)), ]; for (let index = 0; index < maxProjects; index += 1) { const project = item.relatedProjects?.[index]; row.push( normalizeExportText(project?.opportunityCode), normalizeExportText(project?.opportunityName), project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)), ); } for (let index = 0; index < maxContacts; index += 1) { const contact = item.contacts?.[index]; row.push( normalizeExportText(contact?.name), normalizeExportText(contact?.mobile), normalizeExportText(contact?.title), ); } row.push(normalizeExportText(item.notes)); row.push(formatExportFollowUps(item.followUps)); return row; }); } function validateSalesCreateForm(form: CreateSalesExpansionPayload) { const errors: Partial> = {}; if (!form.employeeNo?.trim()) { errors.employeeNo = "请填写工号"; } if (!form.officeName?.trim()) { errors.officeName = "请选择代表处 / 办事处"; } if (!form.candidateName?.trim()) { errors.candidateName = "请填写姓名"; } if (!form.mobile?.trim()) { errors.mobile = "请填写联系方式"; } if (!form.targetDept?.trim()) { errors.targetDept = "请填写所属部门"; } if (!form.industry?.trim()) { errors.industry = "请选择所属行业"; } if (!form.title?.trim()) { errors.title = "请填写职务"; } if (!form.intentLevel?.trim()) { errors.intentLevel = "请选择合作意向"; } if (!form.employmentStatus?.trim()) { errors.employmentStatus = "请选择销售是否在职"; } return errors; } function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOptionValue?: string) { const errors: Partial> = {}; const invalidContactRows: number[] = []; if (!form.channelName?.trim()) { errors.channelName = "请填写渠道名称"; } if (!form.province?.trim()) { errors.province = "请选择省份"; } if (!form.city?.trim()) { errors.city = "请选择市"; } if (!form.officeAddress?.trim()) { errors.officeAddress = "请填写办公地址"; } if (!form.certificationLevel?.trim()) { errors.certificationLevel = "请选择认证级别"; } if ((form.channelIndustry?.length ?? 0) <= 0) { errors.channelIndustry = "请选择聚焦行业"; } if (!form.annualRevenue || form.annualRevenue <= 0) { errors.annualRevenue = "请填写年营收"; } if (!form.staffSize || form.staffSize <= 0) { errors.staffSize = "请填写人员规模"; } if (!form.contactEstablishedDate?.trim()) { errors.contactEstablishedDate = "请选择建立联系时间"; } if (!form.intentLevel?.trim()) { errors.intentLevel = "请选择合作意向"; } if ((form.channelAttribute?.length ?? 0) <= 0) { errors.channelAttribute = "请选择渠道属性"; } if (channelOtherOptionValue && form.channelAttribute?.includes(channelOtherOptionValue) && !form.channelAttributeCustom?.trim()) { errors.channelAttributeCustom = "请选择“其它”后请补充具体渠道属性"; } if ((form.internalAttribute?.length ?? 0) <= 0) { errors.internalAttribute = "请选择新华三内部属性"; } const contacts = form.contacts ?? []; if (contacts.length <= 0) { errors.contacts = "请至少填写一位渠道联系人"; invalidContactRows.push(0); } else { contacts.forEach((contact, index) => { const hasName = Boolean(contact.name?.trim()); const hasMobile = Boolean(contact.mobile?.trim()); const hasTitle = Boolean(contact.title?.trim()); if (!hasName || !hasMobile || !hasTitle) { invalidContactRows.push(index); } }); if (invalidContactRows.length > 0) { errors.contacts = "请完整填写每位渠道联系人的姓名、联系电话和职位"; } } return { errors, invalidContactRows }; } function normalizeSalesPayload(payload: CreateSalesExpansionPayload): CreateSalesExpansionPayload { return { employeeNo: payload.employeeNo.trim(), candidateName: payload.candidateName.trim(), officeName: normalizeOptionalText(payload.officeName), mobile: normalizeOptionalText(payload.mobile), email: normalizeOptionalText(payload.email), targetDept: normalizeOptionalText(payload.targetDept), industry: normalizeOptionalText(payload.industry), title: normalizeOptionalText(payload.title), intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", stage: normalizeOptionalText(payload.stage) ?? "initial_contact", hasDesktopExp: Boolean(payload.hasDesktopExp), inProgress: payload.inProgress ?? true, employmentStatus: normalizeOptionalText(payload.employmentStatus) ?? "active", expectedJoinDate: normalizeOptionalText(payload.expectedJoinDate), remark: normalizeOptionalText(payload.remark), }; } function normalizeChannelPayload(payload: CreateChannelExpansionPayload): CreateChannelExpansionPayload { return { channelCode: normalizeOptionalText(payload.channelCode), officeAddress: normalizeOptionalText(payload.officeAddress), channelIndustry: Array.from(new Set((payload.channelIndustry ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), channelName: payload.channelName.trim(), province: normalizeOptionalText(payload.province), city: normalizeOptionalText(payload.city), certificationLevel: normalizeOptionalText(payload.certificationLevel), annualRevenue: payload.annualRevenue || undefined, staffSize: payload.staffSize || undefined, contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate), intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", hasDesktopExp: Boolean(payload.hasDesktopExp), channelAttribute: Array.from(new Set((payload.channelAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), channelAttributeCustom: normalizeOptionalText(payload.channelAttributeCustom), internalAttribute: Array.from(new Set((payload.internalAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), stage: normalizeOptionalText(payload.stage) ?? "initial_contact", remark: normalizeOptionalText(payload.remark), contacts: (payload.contacts ?? []) .map((contact) => ({ name: normalizeOptionalText(contact.name), mobile: normalizeOptionalText(contact.mobile), title: normalizeOptionalText(contact.title), })) .filter((contact) => contact.name || contact.mobile || contact.title), }; } function normalizeOptionValue(rawValue: string | undefined, options: ExpansionDictOption[]) { const trimmed = rawValue?.trim(); if (!trimmed) { return ""; } const matched = options.find((option) => option.value === trimmed || option.label === trimmed); return matched?.value ?? trimmed; } function normalizeMultiOptionValues(rawValue: string | undefined, options: ExpansionDictOption[]) { const { values } = decodeExpansionMultiValue(rawValue); return Array.from(new Set(values.map((value) => normalizeOptionValue(value, options)).filter(Boolean))); } function ModalShell({ title, subtitle, onClose, children, footer, }: { title: string; subtitle: string; onClose: () => void; children: ReactNode; footer: ReactNode; }) { const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; return ( <>

{title}

{subtitle}

{children}
{footer}
); } function DetailItem({ label, value, icon, className = "", }: { label: string; value: ReactNode; icon?: ReactNode; className?: string; }) { return (

{icon} {label}

{value}
); } function RequiredMark() { return *; } export default function Expansion() { const currentUserId = getStoredCurrentUserId(); const location = useLocation(); const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); const disableMobileMotion = isMobileViewport || isWecomBrowser; 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 [provinceOptions, setProvinceOptions] = useState([]); const [certificationLevelOptions, setCertificationLevelOptions] = useState([]); const [createCityOptions, setCreateCityOptions] = useState([]); const [editCityOptions, setEditCityOptions] = useState([]); const [channelAttributeOptions, setChannelAttributeOptions] = useState([]); const [internalAttributeOptions, setInternalAttributeOptions] = useState([]); const [nextChannelCode, setNextChannelCode] = useState(""); const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? ""; const [refreshTick, setRefreshTick] = useState(0); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [exporting, setExporting] = useState(false); const [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false); const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false); const [createError, setCreateError] = useState(""); const [editError, setEditError] = useState(""); const [exportError, setExportError] = useState(""); const [salesDuplicateMessage, setSalesDuplicateMessage] = useState(""); const [channelDuplicateMessage, setChannelDuplicateMessage] = useState(""); const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState>>({}); const [salesEditFieldErrors, setSalesEditFieldErrors] = useState>>({}); const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState>>({}); const [channelEditFieldErrors, setChannelEditFieldErrors] = useState>>({}); const [invalidCreateChannelContactRows, setInvalidCreateChannelContactRows] = useState([]); const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState([]); const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects"); const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects"); const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId); const [salesForm, setSalesForm] = useState(defaultSalesForm); const [channelForm, setChannelForm] = useState(defaultChannelForm); const [editSalesForm, setEditSalesForm] = useState(defaultSalesForm); const [editChannelForm, setEditChannelForm] = useState(defaultChannelForm); const hasForegroundModal = createOpen || editOpen; const loadMeta = useCallback(async () => { const data = await getExpansionMeta(); setOfficeOptions(data.officeOptions ?? []); setIndustryOptions(data.industryOptions ?? []); setProvinceOptions(data.provinceOptions ?? []); setCertificationLevelOptions(data.certificationLevelOptions ?? []); setChannelAttributeOptions(data.channelAttributeOptions ?? []); setInternalAttributeOptions(data.internalAttributeOptions ?? []); setNextChannelCode(data.nextChannelCode ?? ""); return data; }, []); const loadCityOptions = useCallback(async (provinceName?: string, isEdit = false) => { const setter = isEdit ? setEditCityOptions : setCreateCityOptions; const normalizedProvinceName = provinceName?.trim(); if (!normalizedProvinceName) { setter([]); return []; } try { const options = await getExpansionCityOptions(normalizedProvinceName); setter(options ?? []); return options ?? []; } catch { setter([]); return []; } }, []); 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 loadMetaOptions() { try { const data = await loadMeta(); if (!cancelled) { setOfficeOptions(data.officeOptions ?? []); setIndustryOptions(data.industryOptions ?? []); setProvinceOptions(data.provinceOptions ?? []); setCertificationLevelOptions(data.certificationLevelOptions ?? []); setChannelAttributeOptions(data.channelAttributeOptions ?? []); setInternalAttributeOptions(data.internalAttributeOptions ?? []); setNextChannelCode(data.nextChannelCode ?? ""); } } catch { if (!cancelled) { setOfficeOptions([]); setIndustryOptions([]); setProvinceOptions([]); setCertificationLevelOptions([]); setChannelAttributeOptions([]); setInternalAttributeOptions([]); setNextChannelCode(""); } } } void loadMetaOptions(); return () => { cancelled = true; }; }, [loadMeta]); useEffect(() => { let cancelled = false; async function loadExpansionData() { try { const data = await getExpansionOverview(keyword); if (cancelled) { return; } setSalesData(dedupeExpansionItemsById(data.salesItems ?? [])); setChannelData(dedupeExpansionItemsById(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]); useEffect(() => { if (!createOpen || activeTab !== "sales") { setSalesDuplicateChecking(false); return; } const normalizedEmployeeNo = salesForm.employeeNo.trim(); if (!normalizedEmployeeNo) { setSalesDuplicateChecking(false); setSalesDuplicateMessage(""); return; } let cancelled = false; const timer = window.setTimeout(async () => { setSalesDuplicateChecking(true); try { const result = await checkSalesExpansionDuplicate(normalizedEmployeeNo); if (!cancelled) { setSalesDuplicateMessage(result.duplicated ? result.message || "工号重复,请确认该人员是否已存在!" : ""); } } catch { if (!cancelled) { setSalesDuplicateMessage(""); } } finally { if (!cancelled) { setSalesDuplicateChecking(false); } } }, 400); return () => { cancelled = true; window.clearTimeout(timer); }; }, [activeTab, createOpen, salesForm.employeeNo]); useEffect(() => { if (!createOpen || activeTab !== "channel") { setChannelDuplicateChecking(false); return; } const normalizedChannelName = channelForm.channelName.trim(); if (!normalizedChannelName) { setChannelDuplicateChecking(false); setChannelDuplicateMessage(""); return; } let cancelled = false; const timer = window.setTimeout(async () => { setChannelDuplicateChecking(true); try { const result = await checkChannelExpansionDuplicate(normalizedChannelName); if (!cancelled) { setChannelDuplicateMessage(result.duplicated ? result.message || "渠道重复,请确认该渠道是否已存在!" : ""); } } catch { if (!cancelled) { setChannelDuplicateMessage(""); } } finally { if (!cancelled) { setChannelDuplicateChecking(false); } } }, 400); return () => { cancelled = true; window.clearTimeout(timer); }; }, [activeTab, channelForm.channelName, createOpen]); const handleSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setSalesForm((current) => ({ ...current, [key]: value })); if (key === "employeeNo") { setSalesDuplicateMessage(""); setSalesDuplicateChecking(false); } if (key in salesCreateFieldErrors) { setSalesCreateFieldErrors((current) => { const next = { ...current }; delete next[key as SalesCreateField]; return next; }); } }; const handleChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setChannelForm((current) => ({ ...current, [key]: value })); if (key === "channelName") { setChannelDuplicateMessage(""); setChannelDuplicateChecking(false); } if (key in channelCreateFieldErrors) { setChannelCreateFieldErrors((current) => { const next = { ...current }; delete next[key as ChannelField]; return next; }); } }; const handleEditSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setEditSalesForm((current) => ({ ...current, [key]: value })); if (key in salesEditFieldErrors) { setSalesEditFieldErrors((current) => { const next = { ...current }; delete next[key as SalesCreateField]; return next; }); } }; const handleEditChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setEditChannelForm((current) => ({ ...current, [key]: value })); if (key in channelEditFieldErrors) { setChannelEditFieldErrors((current) => { const next = { ...current }; delete next[key as ChannelField]; return next; }); } }; 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 }; }); if (isEdit) { setChannelEditFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidEditChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); return; } setChannelCreateFieldErrors((current) => { if (!current.contacts) { return current; } const next = { ...current }; delete next.contacts; return next; }); setInvalidCreateChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); }; 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(""); setSalesDuplicateChecking(false); setChannelDuplicateChecking(false); setSalesDuplicateMessage(""); setChannelDuplicateMessage(""); setSalesCreateFieldErrors({}); setChannelCreateFieldErrors({}); setInvalidCreateChannelContactRows([]); setSalesForm(defaultSalesForm); setChannelForm(defaultChannelForm); setCreateCityOptions([]); }; const resetEditState = () => { setEditOpen(false); setEditError(""); setSalesEditFieldErrors({}); setChannelEditFieldErrors({}); setInvalidEditChannelContactRows([]); setEditSalesForm(defaultSalesForm); setEditChannelForm(defaultChannelForm); setEditCityOptions([]); }; const handleOpenCreate = async () => { setCreateError(""); setSalesCreateFieldErrors({}); setChannelCreateFieldErrors({}); setInvalidCreateChannelContactRows([]); try { await loadMeta(); } catch {} setCreateCityOptions([]); setCreateOpen(true); }; const handleOpenEdit = async () => { if (!selectedItem) { return; } if (!canEditSelectedItem) { return; } setEditError(""); let latestIndustryOptions = industryOptions; let latestCertificationLevelOptions = certificationLevelOptions; try { const meta = await loadMeta(); latestIndustryOptions = meta.industryOptions ?? []; latestCertificationLevelOptions = meta.certificationLevelOptions ?? []; } catch {} if (selectedItem.type === "sales") { setSalesEditFieldErrors({}); 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 { const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode); const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode); const normalizedProvinceName = selectedItem.province === "无" ? "" : selectedItem.province ?? ""; const normalizedCityName = selectedItem.city === "无" ? "" : selectedItem.city ?? ""; setChannelEditFieldErrors({}); setInvalidEditChannelContactRows([]); setEditChannelForm({ channelCode: selectedItem.channelCode ?? "", channelName: selectedItem.name ?? "", province: normalizedProvinceName, city: normalizedCityName, officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "", channelIndustry: normalizeMultiOptionValues( selectedItem.channelIndustryCode ?? (selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry), latestIndustryOptions, ), certificationLevel: normalizeOptionValue( selectedItem.certificationLevel === "无" ? "" : selectedItem.certificationLevel, latestCertificationLevelOptions, ) || "", 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: parsedChannelAttributes.values, channelAttributeCustom: parsedChannelAttributes.customText, internalAttribute: parsedInternalAttributes.values, 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()], }); void loadCityOptions(normalizedProvinceName, true); } setEditOpen(true); }; const handleCreateSubmit = async () => { if (submitting) { return; } setCreateError(""); if (activeTab === "sales") { const validationErrors = validateSalesCreateForm(salesForm); if (Object.keys(validationErrors).length > 0) { setSalesCreateFieldErrors(validationErrors); setCreateError("请先完整填写销售人员拓展必填字段"); return; } } else { const { errors: validationErrors, invalidContactRows } = validateChannelForm(channelForm, channelOtherOptionValue); if (Object.keys(validationErrors).length > 0) { setChannelCreateFieldErrors(validationErrors); setInvalidCreateChannelContactRows(invalidContactRows); setCreateError("请先完整填写渠道拓展必填字段"); return; } } if (activeTab === "sales") { const duplicateResult = await checkSalesExpansionDuplicate(salesForm.employeeNo.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!"; setSalesDuplicateMessage(duplicateMessage); setSalesCreateFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage })); setCreateError(duplicateMessage); return; } } else { const duplicateResult = await checkChannelExpansionDuplicate(channelForm.channelName.trim()); if (duplicateResult.duplicated) { const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!"; setChannelDuplicateMessage(duplicateMessage); setChannelCreateFieldErrors((current) => ({ ...current, channelName: duplicateMessage })); setCreateError(duplicateMessage); return; } } setSubmitting(true); try { if (activeTab === "sales") { await createSalesExpansion(normalizeSalesPayload(salesForm)); } else { await createChannelExpansion(normalizeChannelPayload(channelForm)); } resetCreateState(); setRefreshTick((current) => current + 1); } catch (error) { setCreateError(error instanceof Error ? error.message : "新增失败"); } finally { setSubmitting(false); } }; const handleEditSubmit = async () => { if (!selectedItem || submitting) { return; } if (!canEditSelectedItem) { setEditError("仅可编辑本人创建的数据"); return; } setEditError(""); if (selectedItem.type === "sales") { const validationErrors = validateSalesCreateForm(editSalesForm); if (Object.keys(validationErrors).length > 0) { setSalesEditFieldErrors(validationErrors); setEditError("请先完整填写销售人员拓展必填字段"); return; } } else { const { errors: validationErrors, invalidContactRows } = validateChannelForm(editChannelForm, channelOtherOptionValue); if (Object.keys(validationErrors).length > 0) { setChannelEditFieldErrors(validationErrors); setInvalidEditChannelContactRows(invalidContactRows); setEditError("请先完整填写渠道拓展必填字段"); return; } } setSubmitting(true); try { if (selectedItem.type === "sales") { await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm)); } else { await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm)); } 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}

后续规划

{summary.nextPlan}

跟进人: {record.user || "无"}{record.date || "无"}

)})}
); }; const handleTabChange = (tab: ExpansionTab) => { setActiveTab(tab); setSelectedItem(null); setExportError(""); }; const handleExport = async () => { if (exporting) { return; } const isSalesTab = activeTab === "sales"; const items = isSalesTab ? salesData : channelData; if (items.length <= 0) { setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`); return; } setExporting(true); setExportError(""); try { const ExcelJS = await import("exceljs"); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展"); const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData); const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData); worksheet.addRow(headers); rows.forEach((row) => { worksheet.addRow(row); }); 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("备注")) { column.width = 24; } else if (header.includes("项目") && header.includes("名称")) { column.width = 24; } else if (header.includes("渠道属性") || header.includes("内部属性") || header.includes("聚焦行业")) { column.width = 18; } 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 = normalizeExportText(row.getCell(followUpColumnIndex).value as string | null | undefined); const lineCount = followUpText ? followUpText.split("\n").length : 1; row.height = Math.max(22, lineCount * 16); } }); const buffer = await workbook.xlsx.writeBuffer(); const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`; downloadExcelFile(filename, buffer); } catch (error) { setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试"); } finally { setExporting(false); } }; const renderSalesForm = ( form: CreateSalesExpansionPayload, onChange: (key: K, value: CreateSalesExpansionPayload[K]) => void, fieldErrors?: Partial>, isEdit = false, ) => (
); const renderChannelForm = ( form: CreateChannelExpansionPayload, onChange: (key: K, value: CreateChannelExpansionPayload[K]) => void, isEdit = false, fieldErrors?: Partial>, invalidContactRows: number[] = [], ) => { const cityOptions = isEdit ? editCityOptions : createCityOptions; const cityDisabled = !form.province?.trim(); return (
{channelOtherOptionValue && (form.channelAttribute ?? []).includes(channelOtherOptionValue) ? ( ) : null}
人员信息
{(form.contacts ?? []).map((contact, index) => (
handleChannelContactChange(index, "name", e.target.value, isEdit)} placeholder="人员姓名" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "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")} /> handleChannelContactChange(index, "mobile", e.target.value, isEdit)} placeholder="联系电话" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "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")} /> handleChannelContactChange(index, "title", e.target.value, isEdit)} placeholder="职位" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "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")} />
))}
{fieldErrors?.contacts ?

{fieldErrors.contacts}

: null}