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, getOpportunityMeta, 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 ExpansionLocationState = { tab?: ExpansionTab; selectedId?: number } | null; type ExpansionExportFilters = { keyword?: string; intent?: string; officeName?: string; industry?: string; employmentStatus?: string; province?: string; certificationLevel?: string; channelIndustry?: string; channelAttribute?: string; establishedStartDate?: string; establishedEndDate?: string; hasRelatedProject?: string; relatedProjectStageCodes?: string[]; selectedSalesFields?: SalesExportFieldKey[]; selectedChannelFields?: ChannelExportFieldKey[]; }; type SalesExportFieldKey = | "employeeNo" | "name" | "phone" | "officeName" | "dept" | "title" | "industry" | "intent" | "active" | "hasExp" | "relatedProjects" | "relatedProjectAmount" | "owner" | "createdAt" | "updatedAt" | "followUps"; type ChannelExportFieldKey = | "channelCode" | "name" | "province" | "city" | "officeAddress" | "certificationLevel" | "channelIndustry" | "channelAttribute" | "internalAttribute" | "intent" | "establishedDate" | "revenue" | "size" | "hasDesktopExp" | "relatedProjects" | "relatedProjectAmount" | "contacts" | "notes" | "owner" | "createdAt" | "updatedAt" | "followUps"; type ExportColumnKind = "default" | "longText" | "project" | "contact" | "followup"; type ExportCellValue = string | number; type RelatedProjectLike = { opportunityCode?: string; opportunityName?: string; stageCode?: string; stage?: string; amount?: number | null; }; type ExportColumn = { key: K; label: string; kind?: ExportColumnKind; numFmt?: string; value: (item: T) => ExportCellValue; }; 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 CHANNEL_REVENUE_LABEL = "年度营业额(万元)"; const EXPANSION_EXPORT_PREFERENCES_STORAGE_KEY = "crm:expansion-export-preferences"; 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 loadExpansionExportPreferences(): ExpansionExportFilters { if (typeof window === "undefined") { return {}; } try { const rawValue = window.localStorage.getItem(EXPANSION_EXPORT_PREFERENCES_STORAGE_KEY); if (!rawValue) { return {}; } const parsed = JSON.parse(rawValue); return typeof parsed === "object" && parsed ? parsed as ExpansionExportFilters : {}; } catch { return {}; } } function persistExpansionExportPreferences(filters: ExpansionExportFilters) { if (typeof window === "undefined") { return; } try { window.localStorage.setItem(EXPANSION_EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(filters)); } catch { // ignore storage failures } } 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 normalizeExportNumber(value?: string | number | null) { if (value === null || value === undefined) { return undefined; } if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; } const normalized = value.replace(/[¥,\s]|人/g, "").trim(); if (!normalized) { return undefined; } const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : undefined; } function normalizeExportFilterText(value?: string | number | boolean | null) { return normalizeExportText(value).toLowerCase(); } function matchesExportKeyword(value: string, keyword?: string) { const normalizedKeyword = normalizeExportFilterText(keyword); return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword); } function matchesTextFilter(value: string | undefined, filterValue?: string) { const normalizedFilter = normalizeExportFilterText(filterValue); if (!normalizedFilter) { return true; } return normalizeExportFilterText(value).includes(normalizedFilter); } function matchesDateRange(value?: string, startDate?: string, endDate?: string) { const normalizedValue = normalizeExportText(value).slice(0, 10); if (!normalizedValue) { return !startDate && !endDate; } if (startDate && normalizedValue < startDate) { return false; } if (endDate && normalizedValue > endDate) { return false; } return true; } function hasRelatedProjects(projects?: Array<{ amount?: number }>) { return Boolean(projects?.length); } function matchesRelatedProjectFilter(projects: Array<{ amount?: number }> | undefined, filterValue?: string) { if (filterValue === "yes") { return hasRelatedProjects(projects); } if (filterValue === "no") { return !hasRelatedProjects(projects); } return true; } function normalizeMultiSelectValues(values?: string[]) { return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); } function getDictOptionValue(option?: ExpansionDictOption) { const value = option?.value?.trim() || option?.label?.trim() || ""; return value; } function getDictOptionLabel(option?: ExpansionDictOption) { return option?.label?.trim() || option?.value?.trim() || ""; } function isLostProjectStageOption(option?: ExpansionDictOption) { const value = getDictOptionValue(option).toLowerCase(); const label = getDictOptionLabel(option).toLowerCase(); return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃"); } function getDefaultRelatedProjectStageCodes(options: ExpansionDictOption[]) { const orderedValues = options.map(getDictOptionValue).filter(Boolean); const preferredValues = options.filter((option) => !isLostProjectStageOption(option)).map(getDictOptionValue).filter(Boolean); return normalizeMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues); } function applyDefaultRelatedProjectStageFilters(filters: ExpansionExportFilters, options: ExpansionDictOption[]) { if (filters.relatedProjectStageCodes !== undefined) { return { ...filters, relatedProjectStageCodes: normalizeMultiSelectValues(filters.relatedProjectStageCodes), }; } if (options.length <= 0) { return filters; } return { ...filters, relatedProjectStageCodes: getDefaultRelatedProjectStageCodes(options), }; } function areSameStringSets(leftValues?: string[], rightValues?: string[]) { const left = normalizeMultiSelectValues(leftValues).sort(); const right = normalizeMultiSelectValues(rightValues).sort(); return left.length === right.length && left.every((value, index) => value === right[index]); } function filterRelatedProjectsByStage(projects: T[] | undefined, selectedStageCodes?: string[]) { const projectList = projects ?? []; if (selectedStageCodes === undefined) { return projectList; } const normalizedStageCodes = new Set(normalizeMultiSelectValues(selectedStageCodes)); if (normalizedStageCodes.size <= 0) { return []; } return projectList.filter((project) => { const projectStageCode = project.stageCode?.trim(); const projectStageLabel = project.stage?.trim(); return (projectStageCode && normalizedStageCodes.has(projectStageCode)) || (projectStageLabel && normalizedStageCodes.has(projectStageLabel)); }); } function withFilteredSalesRelatedProjects(item: SalesExpansionItem, filters: ExpansionExportFilters): SalesExpansionItem { return { ...item, relatedProjects: filterRelatedProjectsByStage(item.relatedProjects, filters.relatedProjectStageCodes), }; } function withFilteredChannelRelatedProjects(item: ChannelExpansionItem, filters: ExpansionExportFilters): ChannelExpansionItem { return { ...item, relatedProjects: filterRelatedProjectsByStage(item.relatedProjects, filters.relatedProjectStageCodes), }; } function matchesSalesExportFilters(item: SalesExpansionItem, filters: ExpansionExportFilters) { const keywordText = [ item.employeeNo, item.name, item.owner, item.phone, item.officeName, item.dept, item.title, item.industry, item.intent, item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "), item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), ].map(normalizeExportText).filter(Boolean).join(" "); if (!matchesExportKeyword(keywordText, filters.keyword)) { return false; } if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) { return false; } if (!matchesTextFilter(item.officeName, filters.officeName)) { return false; } if (!matchesTextFilter(item.industry, filters.industry)) { return false; } if (filters.employmentStatus === "active" && item.active !== true && item.employmentStatus !== "active") { return false; } if (filters.employmentStatus === "inactive" && item.active !== false && item.employmentStatus !== "inactive") { return false; } return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject); } function matchesChannelExportFilters(item: ChannelExpansionItem, filters: ExpansionExportFilters) { const keywordText = [ item.channelCode, item.name, item.owner, item.province, item.city, item.officeAddress, item.certificationLevel, item.channelIndustry, item.channelAttribute, item.internalAttribute, item.intent, item.primaryContactName, item.primaryContactMobile, item.contacts?.map((contact) => `${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""}`).join(" "), item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "), item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), ].map(normalizeExportText).filter(Boolean).join(" "); if (!matchesExportKeyword(keywordText, filters.keyword)) { return false; } if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) { return false; } if (!matchesTextFilter(item.province, filters.province)) { return false; } if (!matchesTextFilter(item.certificationLevel, filters.certificationLevel)) { return false; } if (!matchesTextFilter(item.channelIndustry, filters.channelIndustry)) { return false; } if (!matchesTextFilter(item.channelAttribute, filters.channelAttribute)) { return false; } if (!matchesDateRange(item.establishedDate, filters.establishedStartDate, filters.establishedEndDate)) { return false; } return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject); } 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 formatExportProjectCell(project?: { opportunityCode?: string; opportunityName?: string; amount?: number | null }) { if (!project) { return ""; } const segments = [ normalizeExportText(project.opportunityCode) ? `编码:${normalizeExportText(project.opportunityCode)}` : "", normalizeExportText(project.opportunityName) ? `项目名称:${normalizeExportText(project.opportunityName)}` : "", project.amount === null || project.amount === undefined ? "" : `金额:${formatAmount(Number(project.amount))}`, ].filter(Boolean); return segments.join("|"); } function formatExportProjectListCell(projects?: Array<{ opportunityCode?: string; opportunityName?: string; amount?: number | null }>) { if (!projects?.length) { return ""; } return projects .map((project) => { const content = formatExportProjectCell(project); return content || ""; }) .filter(Boolean) .join("\n"); } function formatExportContactCell(contact?: { name?: string | null; mobile?: string | null; title?: string | null }) { if (!contact) { return ""; } const segments = [ normalizeExportText(contact.name) ? `姓名:${normalizeExportText(contact.name)}` : "", normalizeExportText(contact.mobile) ? `联系电话:${normalizeExportText(contact.mobile)}` : "", normalizeExportText(contact.title) ? `职位:${normalizeExportText(contact.title)}` : "", ].filter(Boolean); return segments.join("|"); } function formatExportContactListCell(contacts?: Array<{ name?: string | null; mobile?: string | null; title?: string | null }>) { if (!contacts?.length) { return ""; } return contacts .map((contact) => { const content = formatExportContactCell(contact); return content || ""; }) .filter(Boolean) .join("\n"); } function getExcelDisplayWidth(value?: string | null) { if (!value) { return 0; } return Array.from(value).reduce((total, char) => total + (/[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef]/.test(char) ? 2 : 1), 0); } function getExcelWrappedLineCount(value: string | null | undefined, columnWidth: number) { if (!value) { return 1; } const safeWidth = Math.max(1, Math.floor(columnWidth)); return value.split("\n").reduce((total, line) => { const lineWidth = Math.max(1, getExcelDisplayWidth(line)); return total + Math.max(1, Math.ceil(lineWidth / safeWidth)); }, 0); } 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); } const salesExportColumns: Array> = [ { key: "employeeNo", label: "工号", value: (item) => normalizeExportText(item.employeeNo) }, { key: "name", label: "姓名", value: (item) => normalizeExportText(item.name) }, { key: "phone", label: "联系方式", value: (item) => normalizeExportText(item.phone) }, { key: "officeName", label: "代表处/办事处", value: (item) => normalizeExportText(item.officeName) }, { key: "dept", label: "所属部门", value: (item) => normalizeExportText(item.dept) }, { key: "title", label: "职务", value: (item) => normalizeExportText(item.title) }, { key: "industry", label: "所属行业", value: (item) => normalizeExportText(item.industry) }, { key: "intent", label: "合作意向", value: (item) => normalizeExportText(item.intent) }, { key: "active", label: "销售是否在职", value: (item) => (item.active === null || item.active === undefined ? "" : item.active ? "是" : "否") }, { key: "hasExp", label: "销售以前是否做过云桌面项目", value: (item) => formatExportBoolean(item.hasExp) }, { key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) }, { key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, ]; const defaultSalesExportFields: SalesExportFieldKey[] = [ "employeeNo", "name", "phone", "officeName", "dept", "title", "industry", "intent", "active", "hasExp", "relatedProjects", "followUps", ]; const channelExportColumns: Array> = [ { key: "name", label: "渠道名称", value: (item) => normalizeExportText(item.name) }, { key: "province", label: "省份", value: (item) => normalizeExportText(item.province) }, { key: "officeAddress", label: "办公地址", kind: "longText", value: (item) => normalizeExportText(item.officeAddress) }, { key: "channelIndustry", label: "聚焦行业", value: (item) => normalizeExportText(item.channelIndustry) }, { key: "certificationLevel", label: "汇智内部认证级别", value: (item) => normalizeExportText(item.certificationLevel) }, { key: "channelAttribute", label: "渠道属性", value: (item) => normalizeExportText(item.channelAttribute) }, { key: "internalAttribute", label: "新华三内部属性", value: (item) => normalizeExportText(item.internalAttribute) }, { key: "intent", label: "合作意向", value: (item) => normalizeExportText(item.intent) }, { key: "establishedDate", label: "建立联系时间", value: (item) => normalizeExportText(item.establishedDate) }, { key: "revenue", label: "年度营业额", numFmt: "#,##0.00", value: (item) => normalizeExportNumber(item.revenue ?? item.annualRevenue) ?? "" }, { key: "size", label: "人员规模", numFmt: "#,##0", value: (item) => normalizeExportNumber(item.size) ?? "" }, { key: "hasDesktopExp", label: "以前是否做过云桌面项目", value: (item) => formatExportBoolean(item.hasDesktopExp) }, { key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) }, { key: "contacts", label: "人员信息", kind: "contact", value: (item) => formatExportContactListCell(item.contacts) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, { key: "channelCode", label: "编码", value: (item) => normalizeExportText(item.channelCode) }, { key: "city", label: "市", value: (item) => normalizeExportText(item.city) }, { key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" }, { key: "notes", label: "备注说明", kind: "longText", value: (item) => normalizeExportText(item.notes) }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, ]; const defaultChannelExportFields: ChannelExportFieldKey[] = [ "name", "province", "officeAddress", "channelIndustry", "certificationLevel", "channelAttribute", "internalAttribute", "intent", "establishedDate", "revenue", "size", "hasDesktopExp", "relatedProjects", "contacts", "followUps", ]; function resolveSelectedExpansionFields(selectedFields: K[] | undefined, defaultFields: K[]) { return selectedFields === undefined ? defaultFields : selectedFields; } function buildExportRows(items: T[], columns: Array>) { return items.map((item) => columns.map((column) => column.value(item))); } 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 = `请填写${CHANNEL_REVENUE_LABEL}`; } 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 *; } function ExpansionExportFilterModal({ activeTab, initialFilters, exporting, exportError, officeOptions, industryOptions, provinceOptions, certificationLevelOptions, channelAttributeOptions, relatedProjectStageOptions, onClose, onConfirm, }: { activeTab: ExpansionTab; initialFilters: ExpansionExportFilters; exporting: boolean; exportError: string; officeOptions: ExpansionDictOption[]; industryOptions: ExpansionDictOption[]; provinceOptions: ExpansionDictOption[]; certificationLevelOptions: ExpansionDictOption[]; channelAttributeOptions: ExpansionDictOption[]; relatedProjectStageOptions: ExpansionDictOption[]; onClose: () => void; onConfirm: (filters: ExpansionExportFilters) => void; }) { const normalizedInitialFilters = applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions); const [draftFilters, setDraftFilters] = useState(normalizedInitialFilters); const isSalesTab = activeTab === "sales"; const selectedSalesFields = resolveSelectedExpansionFields(draftFilters.selectedSalesFields, defaultSalesExportFields); const selectedChannelFields = resolveSelectedExpansionFields(draftFilters.selectedChannelFields, defaultChannelExportFields); const activeFieldOptions = isSalesTab ? salesExportColumns : channelExportColumns; const selectedFieldKeys = isSalesTab ? selectedSalesFields : selectedChannelFields; const defaultRelatedProjectStageCodes = getDefaultRelatedProjectStageCodes(relatedProjectStageOptions); const relatedProjectStageFilterOptions = relatedProjectStageOptions .map((option) => { const value = getDictOptionValue(option); const label = getDictOptionLabel(option); return value && label ? { value, label } : null; }) .filter((option): option is { value: string; label: string } => Boolean(option)); const selectedRelatedProjectStageCodes = normalizeMultiSelectValues(draftFilters.relatedProjectStageCodes); useEffect(() => { setDraftFilters(applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions)); }, [initialFilters, relatedProjectStageOptions]); const hasDraftFilters = Boolean( draftFilters.keyword || draftFilters.intent || draftFilters.officeName || draftFilters.industry || draftFilters.employmentStatus || draftFilters.province || draftFilters.certificationLevel || draftFilters.channelIndustry || draftFilters.channelAttribute || draftFilters.establishedStartDate || draftFilters.establishedEndDate || draftFilters.hasRelatedProject, ) || !areSameStringSets(selectedRelatedProjectStageCodes, defaultRelatedProjectStageCodes) || (isSalesTab ? JSON.stringify(selectedSalesFields) !== JSON.stringify(defaultSalesExportFields) : JSON.stringify(selectedChannelFields) !== JSON.stringify(defaultChannelExportFields)); const hasSelectedFields = selectedFieldKeys.length > 0; const toggleField = (fieldKey: string) => { if (isSalesTab) { setDraftFilters((current) => { const currentFields = resolveSelectedExpansionFields(current.selectedSalesFields, defaultSalesExportFields); const nextFields = currentFields.includes(fieldKey as SalesExportFieldKey) ? currentFields.filter((item) => item !== fieldKey) : [...currentFields, fieldKey as SalesExportFieldKey]; return { ...current, selectedSalesFields: nextFields }; }); return; } setDraftFilters((current) => { const currentFields = resolveSelectedExpansionFields(current.selectedChannelFields, defaultChannelExportFields); const nextFields = currentFields.includes(fieldKey as ChannelExportFieldKey) ? currentFields.filter((item) => item !== fieldKey) : [...currentFields, fieldKey as ChannelExportFieldKey]; return { ...current, selectedChannelFields: nextFields }; }); }; const handleRelatedProjectStageToggle = (stageCode: string) => { setDraftFilters((current) => { const currentStageCodes = normalizeMultiSelectValues(current.relatedProjectStageCodes); const nextStageCodeSet = currentStageCodes.includes(stageCode) ? new Set(currentStageCodes.filter((value) => value !== stageCode)) : new Set([...currentStageCodes, stageCode]); const orderedStageCodes = relatedProjectStageFilterOptions .map((option) => option.value) .filter((value) => nextStageCodeSet.has(value)); return { ...current, relatedProjectStageCodes: orderedStageCodes }; }); }; const handleFilterChange = (key: keyof ExpansionExportFilters, value: string) => { setDraftFilters((current) => ({ ...current, [key]: value })); }; const renderOption = (option: ExpansionDictOption) => { const value = option.label || option.value || ""; return value ? : null; }; const toSearchableOptions = (options: ExpansionDictOption[], allLabel: string) => [ { value: "", label: allLabel }, ...options .map((option) => { const value = option.label || option.value || ""; return value ? { value, label: value } : null; }) .filter((option): option is { value: string; label: string } => Boolean(option)), ]; return ( )} >

关联项目阶段

多选,默认排除已丢单/已放弃阶段。

{relatedProjectStageFilterOptions.length > 0 ? (
) : null}
{relatedProjectStageFilterOptions.length > 0 ? (
{relatedProjectStageFilterOptions.map((option) => { const checked = selectedRelatedProjectStageCodes.includes(option.value); return ( ); })}
) : (

未加载到阶段字典,导出时不会按关联项目阶段过滤。

)}

导出字段

默认已按模板字段勾选,可取消不需要导出的字段。

{activeFieldOptions.map((field) => { const checked = selectedFieldKeys.includes(field.key); return ( ); })}
{!hasSelectedFields ?

请至少保留一个导出字段

: null}
{isSalesTab ? ( <> ) : ( <> )}
{exportError ?
{exportError}
: null}
); } 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 [relatedProjectStageOptions, setRelatedProjectStageOptions] = 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 [exportFilterOpen, setExportFilterOpen] = useState(false); const [exportFilters, setExportFilters] = useState(() => loadExpansionExportPreferences()); 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 || exportFilterOpen; 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; }, []); useEffect(() => { let cancelled = false; async function loadRelatedProjectStageDict() { try { const data = await getOpportunityMeta(); if (!cancelled) { setRelatedProjectStageOptions(data.stageOptions ?? []); } } catch { if (!cancelled) { setRelatedProjectStageOptions([]); } } } void loadRelatedProjectStageDict(); return () => { cancelled = true; }; }, []); useEffect(() => { if (relatedProjectStageOptions.length <= 0) { return; } setExportFilters((current) => ( current.relatedProjectStageCodes === undefined ? applyDefaultRelatedProjectStageFilters(current, relatedProjectStageOptions) : current )); }, [relatedProjectStageOptions]); 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 ExpansionLocationState)?.tab; if (requestedTab === "sales" || requestedTab === "channel") { setActiveTab(requestedTab); } }, [location.state]); useEffect(() => { const requestedState = location.state as ExpansionLocationState; const requestedTab = requestedState?.tab; const requestedId = requestedState?.selectedId; if (!requestedId) { return; } if (requestedTab === "sales") { const matched = salesData.find((item) => item.id === requestedId); if (matched) { setSelectedItem(matched); } return; } if (requestedTab === "channel") { const matched = channelData.find((item) => item.id === requestedId); if (matched) { setSelectedItem(matched); } return; } const matched = salesData.find((item) => item.id === requestedId) ?? channelData.find((item) => item.id === requestedId) ?? null; if (matched) { setSelectedItem(matched); } }, [location.state, salesData, channelData]); 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 (filters: ExpansionExportFilters) => { if (exporting) { return; } const isSalesTab = activeTab === "sales"; const normalizedFilters = applyDefaultRelatedProjectStageFilters(filters, relatedProjectStageOptions); setExporting(true); setExportError(""); setExportFilters(normalizedFilters); persistExpansionExportPreferences(normalizedFilters); try { const overview = await getExpansionOverview(""); const exportSalesItems = dedupeExpansionItemsById(overview.salesItems ?? []) .map((item) => withFilteredSalesRelatedProjects(item, normalizedFilters)) .filter((item) => matchesSalesExportFilters(item, normalizedFilters)); const exportChannelItems = dedupeExpansionItemsById(overview.channelItems ?? []) .map((item) => withFilteredChannelRelatedProjects(item, normalizedFilters)) .filter((item) => matchesChannelExportFilters(item, normalizedFilters)); const exportItems = isSalesTab ? exportSalesItems : exportChannelItems; const selectedSalesFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedSalesFields, defaultSalesExportFields); const selectedChannelFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedChannelFields, defaultChannelExportFields); const selectedFieldKeys = isSalesTab ? selectedSalesFieldKeys : selectedChannelFieldKeys; if (exportItems.length <= 0) { throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : "渠道拓展"}数据`); } if (selectedFieldKeys.length <= 0) { throw new Error("请至少选择一个导出字段"); } const ExcelJS = await import("exceljs"); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展"); const salesColumns = salesExportColumns.filter((column) => selectedSalesFieldKeys.includes(column.key)); const channelColumns = channelExportColumns.filter((column) => selectedChannelFieldKeys.includes(column.key)); const columns = isSalesTab ? salesColumns : channelColumns; const headers = columns.map((column) => column.label); const rows = isSalesTab ? buildExportRows(exportSalesItems, salesColumns) : buildExportRows(exportChannelItems, channelColumns); worksheet.addRow(headers); rows.forEach((row) => { worksheet.addRow(row); }); worksheet.views = [{ state: "frozen", ySplit: 1 }]; worksheet.getRow(1).height = 24; worksheet.getRow(1).font = { bold: true }; worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" }; const columnWidths = columns.map((column, index) => { const columnValues = rows.map((row) => row[index]).filter((value): value is string => typeof value === "string"); if (column.kind === "followup") { return 42; } if (column.kind === "project" || column.kind === "contact") { return Math.min( 80, Math.max( 16, getExcelDisplayWidth(column.label) + 2, columnValues.reduce((maxWidth, value) => { const longestLineWidth = value.split("\n").reduce((lineMax, line) => Math.max(lineMax, getExcelDisplayWidth(line)), 0); return Math.max(maxWidth, longestLineWidth + 2); }, 0), ), ); } if (column.kind === "longText") { return 24; } if (column.label.includes("渠道属性") || column.label.includes("内部属性") || column.label.includes("聚焦行业")) { return 18; } return 16; }); columns.forEach((columnConfig, index) => { const column = worksheet.getColumn(index + 1); column.width = columnWidths[index]; if (columnConfig.numFmt) { column.numFmt = columnConfig.numFmt; } column.alignment = { vertical: "top", horizontal: "left", wrapText: columnConfig.kind === "followup" || columnConfig.kind === "project" || columnConfig.kind === "contact", }; }); 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: rowNumber > 1 && (columns[columnNumber - 1]?.kind === "followup" || columns[columnNumber - 1]?.kind === "project" || columns[columnNumber - 1]?.kind === "contact"), }; }); if (rowNumber > 1) { const wrappedLineCount = columns.reduce((maxLineCount, column, index) => { if (column.kind !== "project" && column.kind !== "contact" && column.kind !== "followup") { return maxLineCount; } const cellValue = row.getCell(index + 1).value; const text = typeof cellValue === "string" ? cellValue : ""; return Math.max(maxLineCount, getExcelWrappedLineCount(text, columnWidths[index])); }, 1); row.height = Math.max(22, wrappedLineCount * 16); } }); const buffer = await workbook.xlsx.writeBuffer(); const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`; downloadExcelFile(filename, buffer); setExportFilterOpen(false); } 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}