unis_crm/frontend/src/pages/Expansion.tsx

5676 lines
270 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronDown, ChevronRight, Check } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
import {
canUsePermission,
checkChannelExpansionDuplicate,
checkCrmExpansionDuplicate,
checkSalesExpansionDuplicate,
createChannelExpansion,
createCrmExpansion,
createSalesExpansion,
decodeExpansionMultiValue,
getCrmExpansionOverview,
getExpansionCityOptions,
getExpansionMeta,
getExpansionOverview,
getOpportunityExpansionOptions,
getOpportunityMeta,
getStoredCurrentUserId,
listMyPermissions,
moveChannelToCrm,
moveCrmToChannel,
updateChannelExpansion,
updateCrmExpansion,
updateSalesExpansion,
type ChannelExpansionContact,
type ChannelExpansionItem,
type CrmExpansionContact,
type CrmExpansionItem,
type CrmId,
type CreateChannelExpansionPayload,
type CreateCrmExpansionPayload,
type CreateSalesExpansionPayload,
type ExpansionDictOption,
type ExpansionFollowUp,
type MoveChannelToCrmPayload,
type MoveCrmToChannelPayload,
type SalesExpansionItem,
type UpdateCrmExpansionPayload,
} from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { SearchableSelect, type SearchableOption } from "@/components/SearchableSelect";
import { SearchOrInputSelect } from "@/components/SearchOrInputSelect";
import {
ChannelContactRows,
buildChannelContactRows,
createDefaultChannelContacts,
validateChannelContactRows,
} from "@/features/crmQuickCreate/shared";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem | CrmExpansionItem;
const LIST_PAGE_SIZE = 10;
const SUPPLIER_SEARCH_LIMIT = 20;
const SUPPLIER_SEARCH_DEBOUNCE_MS = 300;
type ExpansionTab = "sales" | "channel" | "crm";
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | 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[];
selectedCrmFields?: CrmExportFieldKey[];
};
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"
| "coverageItems"
| "certificationLevel"
| "channelIndustry"
| "channelAttribute"
| "internalAttribute"
| "intent"
| "establishedDate"
| "revenue"
| "size"
| "registeredCapital"
| "hasDesktopExp"
| "relatedProjects"
| "relatedProjectAmount"
| "contacts"
| "notes"
| "owner"
| "createdAt"
| "updatedAt"
| "followUps";
type CrmExportFieldKey =
| "endUser"
| "officeName"
| "industryAttr"
| "extensionType"
| "purchaseDateText"
| "warrantyExpiryText"
| "onlineStatus"
| "supplierName"
| "h3cContactName"
| "hasExpansionOpportunity"
| "softwarePoints"
| "expansionTimeText"
| "expansionScale"
| "hasMaintenanceOpportunity"
| "contacts"
| "followUps"
| "owner"
| "createdAt"
| "updatedAt";
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<T, K extends string> = {
key: K;
label: string;
kind?: ExportColumnKind;
numFmt?: string;
value: (item: T) => ExportCellValue;
};
type SalesCreateField =
| "employeeNo"
| "officeName"
| "candidateName"
| "mobile"
| "targetDept"
| "industry"
| "title"
| "intentLevel"
| "employmentStatus"
| "regionProvince"
| "regionCity"
| "regionItems";
type ChannelField =
| "channelName"
| "province"
| "city"
| "coverageProvince"
| "coverageCity"
| "coverageItems"
| "officeAddress"
| "channelIndustry"
| "certificationLevel"
| "annualRevenue"
| "staffSize"
| "registeredCapital"
| "contactEstablishedDate"
| "intentLevel"
| "channelAttribute"
| "channelAttributeCustom"
| "internalAttribute"
| "contacts";
function createEmptyCrmContact(): CrmExpansionContact {
return {
name: "",
mobile: "",
title: "",
};
}
const CHANNEL_REVENUE_LABEL = "年度营业额(万元)";
const CHANNEL_REGISTERED_CAPITAL_LABEL = "注册资金(万元)";
const EXPANSION_CREATE_PERMISSION = "expansion:create";
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",
regionProvince: [],
regionCity: [],
regionItems: [],
};
const defaultChannelForm: CreateChannelExpansionPayload = {
channelCode: "",
channelName: "",
province: "",
city: "",
coverageProvince: [],
coverageCity: [],
coverageItems: [],
officeAddress: "",
channelIndustry: [],
certificationLevel: "",
contactEstablishedDate: "",
intentLevel: "medium",
hasDesktopExp: false,
channelAttribute: [],
channelAttributeCustom: "",
internalAttribute: [],
stage: "initial_contact",
remark: "",
contacts: createDefaultChannelContacts(),
};
const defaultMoveChannelForm: MoveChannelToCrmPayload = {
officeName: "",
extensionType: [],
purchaseDate: "",
warrantyExpiry: "",
onlineStatus: "",
supplierId: 0,
supplierName: "",
h3cContactId: 0,
h3cContactName: "",
hasExpansionOpportunity: "",
softwarePoints: undefined,
expansionTime: "",
expansionScale: "",
hasMaintenanceOpportunity: "",
contacts: [],
};
const defaultMoveCrmForm: MoveCrmToChannelPayload = {
province: "",
city: "",
officeAddress: "",
certificationLevel: "",
annualRevenue: 0,
staffSize: 0,
registeredCapital: 0,
channelAttribute: [],
channelAttributeCustom: "",
internalAttribute: [],
coverageProvince: [],
coverageCity: [],
coverageItems: [],
intentLevel: "",
hasDesktopExp: false,
stage: "initial_contact",
landedFlag: false,
expectedSignDate: "",
contacts: createDefaultChannelContacts(),
};
/** 在当前日期基础上加指定年数,返回 YYYY-MM-DD */
function addYearsToDate(dateText: string, years: number) {
const normalized = dateText?.trim();
if (!normalized) {
return "";
}
const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return normalized;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return normalized;
}
const next = new Date(year + years, month - 1, day);
const nextYear = next.getFullYear();
const nextMonth = `${next.getMonth() + 1}`.padStart(2, "0");
const nextDay = `${next.getDate()}`.padStart(2, "0");
return `${nextYear}-${nextMonth}-${nextDay}`;
}
/** 尝试用省份中文名匹配代表处字典项,匹配到返回字典 value否则返回空 */
function matchOfficeValueByProvince(province: string | undefined, officeOptions: ExpansionDictOption[]) {
const normalized = province?.trim();
if (!normalized) {
return "";
}
const exactMatch = officeOptions.find((option) => option.label === normalized);
if (exactMatch) {
return exactMatch.value ?? "";
}
// cnarea 省份为全称(如"重庆市"),字典 label 为简称(如"重庆"),按前缀匹配回退
const prefixMatch = officeOptions.find((option) => {
const label = option.label?.trim();
return label ? normalized.startsWith(label) : false;
});
return prefixMatch?.value ?? "";
}
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<T extends { id?: number | string | null }>(items: T[]) {
const seenIds = new Set<number | string>();
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;
}
// 后端 coverageItems 形如「省份|城市,省份|城市」,导出时转为「省份-城市、省份-城市」。
function formatCoverageItemsExport(value?: string) {
if (!value?.trim()) {
return "";
}
return value
.split("")
.map((part) => {
const [province, city] = part.split("|");
const p = province?.trim() || "";
const c = city?.trim() || "";
return c ? `${p}-${c}` : p;
})
.filter(Boolean)
.join("、");
}
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 getDictLabelByValue(value: string | undefined | null, options: ExpansionDictOption[]) {
const target = value?.trim();
if (!target) {
return "";
}
const matched = options.find((option) => option.value?.trim() === target);
return matched?.label?.trim() || target;
}
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<T extends RelatedProjectLike>(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.duty ?? ""} ${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""} ${contact.wecomAdded ?? ""} ${contact.specialNote ?? ""}`).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 matchesCrmExportFilters(item: CrmExpansionItem, filters: ExpansionExportFilters, officeOptions: ExpansionDictOption[]) {
const keywordText = [
item.endUser,
item.owner,
getDictLabelByValue(item.officeName, officeOptions),
item.industryAttr,
item.supplierName,
item.h3cContactName,
item.contacts?.map((contact) => `${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""}`).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 (filters.officeName && getDictLabelByValue(item.officeName, officeOptions) !== filters.officeName) {
return false;
}
return true;
}
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?: {
duty?: string | null;
name?: string | null;
mobile?: string | null;
title?: string | null;
birthday?: string | null;
wecomAdded?: string | null;
specialNote?: string | null;
},
wecomLabelByValue?: (value: string | null | undefined) => string,
) {
if (!contact) {
return "";
}
const wecomLabel = wecomLabelByValue ? wecomLabelByValue(contact.wecomAdded) : normalizeExportText(contact.wecomAdded);
const segments = [
normalizeExportText(contact.duty) ? `工作职责:${normalizeExportText(contact.duty)}` : "",
normalizeExportText(contact.name) ? `姓名:${normalizeExportText(contact.name)}` : "",
normalizeExportText(contact.title) ? `职务:${normalizeExportText(contact.title)}` : "",
normalizeExportText(contact.mobile) ? `联系电话:${normalizeExportText(contact.mobile)}` : "",
normalizeExportText(contact.birthday) ? `生日:${normalizeExportText(contact.birthday)}` : "",
wecomLabel ? `是否加企业微信:${wecomLabel}` : "",
normalizeExportText(contact.specialNote) ? `特别说明:${normalizeExportText(contact.specialNote)}` : "",
].filter(Boolean);
return segments.join("");
}
function formatExportContactListCell(
contacts?: Array<{
duty?: string | null;
name?: string | null;
mobile?: string | null;
title?: string | null;
birthday?: string | null;
wecomAdded?: string | null;
specialNote?: string | null;
}>,
wecomLabelByValue?: (value: string | null | undefined) => string,
) {
if (!contacts?.length) {
return "";
}
return contacts
.map((contact) => {
const content = formatExportContactCell(contact, wecomLabelByValue);
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<ExportColumn<SalesExpansionItem, SalesExportFieldKey>> = [
{ 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",
];
function buildChannelExportColumns(isOptions: ExpansionDictOption[]): Array<ExportColumn<ChannelExpansionItem, ChannelExportFieldKey>> {
const wecomLabelByValue = (value: string | null | undefined) => getDictLabelByValue(value, isOptions);
return [
{ key: "name", label: "渠道名称", value: (item) => normalizeExportText(item.name) },
{ key: "province", label: "省份", value: (item) => normalizeExportText(item.province) },
{ key: "city", label: "市", value: (item) => normalizeExportText(item.city) },
{ key: "officeAddress", label: "办公地址", kind: "longText", value: (item) => normalizeExportText(item.officeAddress) },
{ key: "coverageItems", label: "覆盖地市", value: (item) => formatCoverageItemsExport(item.coverageItems) },
{ 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.annualRevenue) ?? normalizeExportNumber(item.revenue) ?? "" },
{ key: "size", label: "人员规模", numFmt: "#,##0", value: (item) => normalizeExportNumber(item.size) ?? "" },
{ key: "registeredCapital", label: "注册资金", numFmt: "#,##0.00", value: (item) => normalizeExportNumber(item.registeredCapital) ?? "" },
{ 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, wecomLabelByValue) },
{ key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) },
{ key: "channelCode", label: "编码", value: (item) => normalizeExportText(item.channelCode) },
{ 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",
"city",
"officeAddress",
"channelIndustry",
"certificationLevel",
"channelAttribute",
"internalAttribute",
"intent",
"establishedDate",
"revenue",
"size",
"registeredCapital",
"hasDesktopExp",
"relatedProjects",
"contacts",
"followUps",
];
function buildCrmExportColumns(officeOptions: ExpansionDictOption[]): Array<ExportColumn<CrmExpansionItem, CrmExportFieldKey>> {
return [
{ key: "endUser", label: "最终用户", value: (item) => normalizeExportText(item.endUser) },
{ key: "officeName", label: "代表处", value: (item) => getDictLabelByValue(item.officeName, officeOptions) },
{ key: "industryAttr", label: "行业属性", value: (item) => normalizeExportText(item.industryAttr) },
{ key: "extensionType", label: "类型", value: (item) => normalizeExportText(item.extensionType) },
{ key: "purchaseDateText", label: "采购时间", value: (item) => normalizeExportText(item.purchaseDateText) },
{ key: "warrantyExpiryText", label: "过保时间", value: (item) => normalizeExportText(item.warrantyExpiryText) },
{ key: "onlineStatus", label: "在线情况", value: (item) => normalizeExportText(item.onlineStatus) },
{ key: "supplierName", label: "进货商", value: (item) => normalizeExportText(item.supplierName) },
{ key: "h3cContactName", label: "新华三对接人", value: (item) => normalizeExportText(item.h3cContactName) },
{ key: "hasExpansionOpportunity", label: "是否有扩容机会", value: (item) => (item.hasExpansionOpportunity === "1" ? "是" : item.hasExpansionOpportunity === "0" ? "否" : "") },
{ key: "softwarePoints", label: "软件点数", value: (item) => (item.softwarePoints == null ? "" : String(item.softwarePoints)) },
{ key: "expansionTimeText", label: "扩容时间", value: (item) => normalizeExportText(item.expansionTimeText) },
{ key: "expansionScale", label: "扩容规模", value: (item) => normalizeExportText(item.expansionScale) },
{ key: "hasMaintenanceOpportunity", label: "是否有维保项目机会", value: (item) => (item.hasMaintenanceOpportunity === "1" ? "是" : item.hasMaintenanceOpportunity === "0" ? "否" : "") },
{ key: "contacts", label: "人员信息", kind: "contact", value: (item) => formatExportContactListCell(item.contacts) },
{ key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) },
{ 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 defaultCrmExportFields: CrmExportFieldKey[] = [
"endUser",
"officeName",
"industryAttr",
"extensionType",
"purchaseDateText",
"warrantyExpiryText",
"onlineStatus",
"softwarePoints",
"expansionTimeText",
"expansionScale",
"hasMaintenanceOpportunity",
"supplierName",
"h3cContactName",
"hasExpansionOpportunity",
"contacts",
"followUps",
];
function resolveSelectedExpansionFields<K extends string>(selectedFields: K[] | undefined, defaultFields: K[]) {
return selectedFields === undefined ? defaultFields : selectedFields;
}
function buildExportRows<T, K extends string>(items: T[], columns: Array<ExportColumn<T, K>>) {
return items.map((item) => columns.map((column) => column.value(item)));
}
function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
const errors: Partial<Record<SalesCreateField, string>> = {};
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.regionProvince?.length ?? 0) <= 0) {
errors.regionProvince = "请选择所属区域";
}
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<Record<ChannelField, string>> = {};
if (!form.channelName?.trim()) {
errors.channelName = "请填写渠道名称";
}
if (!form.province?.trim()) {
errors.province = "请选择省份";
}
if (!form.city?.trim()) {
errors.city = "请选择市";
}
if ((form.coverageProvince?.length ?? 0) <= 0) {
errors.coverageProvince = "请选择覆盖省份";
}
if ((form.coverageCity?.length ?? 0) <= 0) {
errors.coverageCity = "请选择覆盖市/区/县";
}
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.registeredCapital || form.registeredCapital <= 0) {
errors.registeredCapital = `请填写${CHANNEL_REGISTERED_CAPITAL_LABEL}`;
}
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 contactValidation = validateChannelContactRows(form.contacts);
if (contactValidation.error) {
errors.contacts = contactValidation.error;
}
return { errors, invalidContactRows: contactValidation.invalidContactRows };
}
function validateCrmForm(form: CreateCrmExpansionPayload) {
const errors: Partial<Record<keyof CreateCrmExpansionPayload, string>> = {};
const invalidContactRows: number[] = [];
if (!form.endUser?.trim()) errors.endUser = "请填写最终用户";
if (!form.officeName?.trim()) errors.officeName = "请选择代表处";
if ((form.industryAttr?.length ?? 0) <= 0) errors.industryAttr = "请选择行业属性";
if ((form.extensionType?.length ?? 0) <= 0) errors.extensionType = "请选择类型";
if (form.softwarePoints == null || Number.isNaN(form.softwarePoints) || form.softwarePoints < 0) errors.softwarePoints = "请填写软件点数";
if (!form.purchaseDate?.trim()) errors.purchaseDate = "请选择采购时间";
if (!form.warrantyExpiry?.trim()) errors.warrantyExpiry = "请选择过保时间";
if (!form.onlineStatus?.trim()) errors.onlineStatus = "请选择在线情况";
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 = "请完整填写每位联系人的姓名、联系电话和职位";
}
}
if ((!form.supplierId || Number(form.supplierId) <= 0) && !form.supplierName?.trim()) errors.supplierId = "请选择或填写进货商";
if ((!form.h3cContactId || Number(form.h3cContactId) <= 0) && !form.h3cContactName?.trim()) errors.h3cContactId = "请选择或填写新华三对接人";
if (!form.hasExpansionOpportunity?.trim()) errors.hasExpansionOpportunity = "请选择是否有扩容机会";
if (form.hasExpansionOpportunity === "1") {
if (!form.expansionTime?.trim()) errors.expansionTime = "请选择扩容时间";
if (!form.expansionScale?.trim()) errors.expansionScale = "请填写扩容规模";
}
if (!form.hasMaintenanceOpportunity?.trim()) errors.hasMaintenanceOpportunity = "请选择是否有维保项目机会";
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),
regionProvince: Array.from(new Set((payload.regionProvince ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
regionCity: Array.from(new Set((payload.regionCity ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
regionItems: (payload.regionItems ?? [])
.map((item) => ({
province: normalizeOptionalText(item.province) ?? "",
city: normalizeOptionalText(item.city) ?? "",
}))
.filter((item) => item.province),
};
}
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),
coverageProvince: Array.from(new Set((payload.coverageProvince ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
coverageCity: Array.from(new Set((payload.coverageCity ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
coverageItems: (payload.coverageItems ?? [])
.map((item) => ({
province: normalizeOptionalText(item.province) ?? "",
city: normalizeOptionalText(item.city) ?? "",
}))
.filter((item) => item.province),
certificationLevel: normalizeOptionalText(payload.certificationLevel),
annualRevenue: payload.annualRevenue || undefined,
staffSize: payload.staffSize || undefined,
registeredCapital: payload.registeredCapital || 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) => ({
duty: normalizeOptionalText(contact.duty),
name: normalizeOptionalText(contact.name),
mobile: normalizeOptionalText(contact.mobile),
title: normalizeOptionalText(contact.title),
birthday: normalizeOptionalText(contact.birthday),
wecomAdded: normalizeOptionalText(contact.wecomAdded),
specialNote: normalizeOptionalText(contact.specialNote),
}))
.filter((contact) => contact.name || contact.mobile || contact.title || contact.birthday || contact.wecomAdded || contact.specialNote),
};
}
/**
* 将后端返回的覆盖地市字符串(形如 "浙江省|杭州市,江苏省|南京市")解析为 [{ province, city }] 数组,
* 用于编辑回显 coverageItems避免保存时因 coverageItems 为空而清空已保存的覆盖地市。
*/
function parseCoverageItemsString(rawValue?: string) {
if (!rawValue) {
return [];
}
const items: { province: string; city: string }[] = [];
rawValue
.split("")
.map((segment) => segment.trim())
.filter(Boolean)
.forEach((segment) => {
const [province = "", city = ""] = segment.split("|");
if (province) {
items.push({ province: province.trim(), city: city.trim() });
}
});
return items;
}
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 (
<>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={disableMobileMotion ? undefined : { opacity: 0 }}
onClick={onClose}
className={cn("fixed inset-0 z-[70] bg-slate-900/35 dark:bg-slate-950/70", !disableMobileMotion && "backdrop-blur-sm")}
/>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={disableMobileMotion ? undefined : { opacity: 0, y: 20 }}
transition={disableMobileMotion ? { duration: 0 } : undefined}
className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
>
<div className="mx-auto flex h-[calc(100dvh-env(safe-area-inset-top))] w-full items-end sm:h-full sm:max-w-3xl sm:items-center">
<div className="flex h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:h-full sm:rounded-3xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{title}</h2>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">{subtitle}</p>
</div>
<button onClick={onClose} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
<div className="border-t border-slate-100 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 dark:border-slate-800 sm:px-6 sm:pb-4">{footer}</div>
</div>
</div>
</motion.div>
</>
);
}
function DetailItem({
label,
value,
icon,
className = "",
}: {
label: string;
value: ReactNode;
icon?: ReactNode;
className?: string;
}) {
return (
<div className={`crm-detail-item ${className}`.trim()}>
<p className="crm-detail-label">
{icon}
{label}
</p>
<div className="crm-detail-value">{value}</div>
</div>
);
}
function RequiredMark() {
return <span className="ml-1 text-rose-500">*</span>;
}
type ChannelAddressValue = {
province?: string;
city?: string;
officeAddress?: string;
};
type ChannelAddressErrors = Partial<Record<keyof ChannelAddressValue, string>>;
/**
* 地区级联选择器:选取省份后联动展示城市,作为单一复合控件使用(前端不分两个字段)。
*/
function AddressCascaderSelect({
value,
onChange,
provinceOptions,
getCities,
isEdit,
hasError,
}: {
value: { province?: string; city?: string };
onChange: (value: { province?: string; city?: string }) => void;
provinceOptions: ExpansionDictOption[];
getCities: (provinceName: string | undefined, isEdit: boolean) => Promise<ExpansionDictOption[]>;
isEdit: boolean;
hasError?: boolean;
}) {
const [open, setOpen] = useState(false);
const [activeProvince, setActiveProvince] = useState<string>("");
const [cityOptions, setCityOptions] = useState<ExpansionDictOption[]>([]);
const [loadingCity, setLoadingCity] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const selectedProvinceLabel =
provinceOptions.find((option) => option.value === value?.province)?.label ?? value?.province ?? "";
const loadCities = async (province: string) => {
setLoadingCity(true);
setCityOptions([]);
try {
const options = await getCities(province, isEdit);
setCityOptions(options ?? []);
} catch {
setCityOptions([]);
} finally {
setLoadingCity(false);
}
};
useEffect(() => {
if (!open) {
return;
}
const initialProvince = value?.province || "";
setActiveProvince(initialProvince);
if (initialProvince) {
void loadCities(initialProvince);
} else {
setCityOptions([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !panelRef.current?.contains(targetNode)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);
const selectedCityLabel =
activeProvince
? (cityOptions.find((option) => option.value === value?.city)?.label ?? value?.city ?? "")
: "";
const displayText = selectedProvinceLabel
? selectedCityLabel
? `${selectedProvinceLabel} / ${selectedCityLabel}`
: `${selectedProvinceLabel} / 请选择市`
: "请选择省份";
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
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"
: "",
)}
>
<span className={value?.province ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
{displayText}
</span>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
</button>
{open ? (
<div
ref={panelRef}
className="absolute left-0 top-full z-50 mt-2 w-[26rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex max-h-80">
<div className="w-1/2 space-y-1 overflow-y-auto border-r border-slate-100 p-2 dark:border-slate-800">
<p className="px-3 pb-1 text-xs font-semibold text-slate-400"></p>
{provinceOptions.map((option) => {
const isActive = activeProvince === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => {
setActiveProvince(option.value);
void loadCities(option.value);
// 省份变更同步到表单并重置市,避免显示仍停留在原省份/原市
onChange({ province: option.value, city: "" });
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors",
isActive
? "bg-violet-600 text-white"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
)}
>
<span className="break-anywhere">{option.label || option.value}</span>
{isActive ? <ChevronRight className="h-4 w-4 shrink-0" /> : null}
</button>
);
})}
</div>
<div className="w-1/2 space-y-1 overflow-y-auto p-2">
<p className="px-3 pb-1 text-xs font-semibold text-slate-400"></p>
{loadingCity ? (
<div className="px-3 py-6 text-center text-sm text-slate-400"></div>
) : !activeProvince ? (
<div className="px-3 py-6 text-center text-sm text-slate-400"></div>
) : cityOptions.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-slate-400"></div>
) : (
cityOptions.map((option) => {
const isSelected = value?.province === activeProvince && value?.city === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange({ province: activeProvince, city: option.value });
setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors",
isSelected
? "bg-violet-600 text-white"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
)}
>
<span className="break-anywhere">{option.label || option.value}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
);
})
)}
</div>
</div>
</div>
) : null}
</div>
);
}
/**
* 渠道地址复合字段:省份、市、办公地址 作为一个前端字段维护。
* 内部仍保存到三个数据库字段,但对外呈现为统一的地址区块。
*/
function ChannelAddressField({
value,
onChange,
provinceOptions,
isEdit,
errors,
loadCityOptions,
}: {
value: ChannelAddressValue;
onChange: (value: ChannelAddressValue) => void;
provinceOptions: ExpansionDictOption[];
isEdit: boolean;
errors?: ChannelAddressErrors;
loadCityOptions: (provinceName: string | undefined, isEdit: boolean) => Promise<ExpansionDictOption[]>;
}) {
const addressValue = { province: value.province, city: value.city };
const officeAddress = value.officeAddress || "";
const areaHasError = Boolean(errors?.province || errors?.city);
return (
<>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AddressCascaderSelect
value={addressValue}
onChange={(next) => {
onChange({ ...value, province: next.province, city: next.city });
}}
provinceOptions={provinceOptions}
getCities={loadCityOptions}
isEdit={isEdit}
hasError={areaHasError}
/>
{areaHasError ? <p className="text-xs text-rose-500">{errors?.province || errors?.city}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
value={officeAddress}
placeholder="请填写详细地址"
onChange={(e) => onChange({ ...value, officeAddress: e.target.value })}
className={getFieldInputClass(Boolean(errors?.officeAddress))}
/>
{errors?.officeAddress ? <p className="text-xs text-rose-500">{errors.officeAddress}</p> : null}
</label>
</>
);
}
/**
* 覆盖地市级联选择器(多选):省可多选,选中省后联动勾选该省城市,作为单一复合控件使用。
* 对外仍以 coverageProvince / coverageCity 两个数组维护。
*/
function CoverageCascaderSelect({
value,
onChange,
provinceOptions,
getCities,
isEdit,
hasError,
}: {
value: { provinces?: string[]; cities?: string[]; items?: { province?: string; city?: string }[] };
onChange: (value: { provinces: string[]; cities: string[]; items: { province: string; city: string }[] }) => void;
provinceOptions: ExpansionDictOption[];
getCities: (provinceName: string | undefined, isEdit: boolean) => Promise<ExpansionDictOption[]>;
isEdit: boolean;
hasError?: boolean;
}) {
const [open, setOpen] = useState(false);
const [selection, setSelection] = useState<Record<string, string[]>>({});
const [cityCache, setCityCache] = useState<Record<string, ExpansionDictOption[]>>({});
const [loadingKeys, setLoadingKeys] = useState<Record<string, boolean>>({});
const [activeProvince, setActiveProvince] = useState<string>("");
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
(async () => {
const provinces = value?.provinces ?? [];
const cities = value?.cities ?? [];
const nextSelection: Record<string, string[]> = {};
const nextCache: Record<string, ExpansionDictOption[]> = {};
for (const province of provinces) {
let options: ExpansionDictOption[] = [];
try {
options = (await getCities(province, isEdit)) ?? [];
} catch {
options = [];
}
nextCache[province] = options;
const optionValues = new Set(options.map((option) => option.value));
nextSelection[province] = cities.filter((city) => optionValues.has(city));
}
setCityCache(nextCache);
setSelection(nextSelection);
setActiveProvince(provinces[0] ?? "");
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) {
return;
}
const provinces = Object.keys(selection);
const cities = provinces.flatMap((province) => selection[province] ?? []);
const items = provinces.flatMap((province) => {
const provinceCities = selection[province] ?? [];
return provinceCities.length > 0
? provinceCities.map((city) => ({ province, city }))
: [{ province, city: "" }];
});
onChange({ provinces, cities, items });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selection]);
// 打开编辑(或回显)时,依据已保存的成对 coverageItems 直接初始化 selection
// 使“省份:市/区/县”的对应关系在下拉未打开时也能正常展示。
const hydratedCoverageRef = useRef("");
useEffect(() => {
if (open) {
return;
}
const items = value?.items ?? [];
const signature = items.map((item) => `${item.province}|${item.city}`).join("");
if (!signature || hydratedCoverageRef.current === signature) {
return;
}
hydratedCoverageRef.current = signature;
const nextSelection: Record<string, string[]> = {};
for (const item of items) {
if (!item.province) {
continue;
}
const provinceCities = nextSelection[item.province] ?? (nextSelection[item.province] = []);
if (item.city && !provinceCities.includes(item.city)) {
provinceCities.push(item.city);
}
}
setSelection(nextSelection);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value?.items, open]);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !panelRef.current?.contains(targetNode)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);
const loadProvinceCities = async (province: string) => {
if (cityCache[province]) {
return;
}
setLoadingKeys((current) => ({ ...current, [province]: true }));
try {
const options = (await getCities(province, isEdit)) ?? [];
setCityCache((current) => ({ ...current, [province]: options }));
} catch {
setCityCache((current) => ({ ...current, [province]: [] }));
} finally {
setLoadingKeys((current) => {
const next = { ...current };
delete next[province];
return next;
});
}
};
const toggleProvince = (province: string) => {
setSelection((current) => {
const next = { ...current };
if (province in next) {
delete next[province];
} else {
next[province] = [];
}
return next;
});
setActiveProvince(province);
void loadProvinceCities(province);
};
const toggleCity = (city: string) => {
if (!activeProvince) {
return;
}
setSelection((current) => {
const provinceCities = current[activeProvince] ?? [];
const nextCities = provinceCities.includes(city)
? provinceCities.filter((item) => item !== city)
: [...provinceCities, city];
return { ...current, [activeProvince]: nextCities };
});
};
const selectedCount = (Object.values(selection) as string[][]).reduce((sum, cities) => sum + cities.length, 0);
// 面板未打开时如刚进入编辑selection 尚未初始化,直接依据 value 回显已保存的覆盖地市,
// 避免编辑时"覆盖地市"显示为空。
const valueProvinces = (value?.provinces ?? []).filter(Boolean);
const hasSelection = Object.keys(selection).length > 0 || selectedCount > 0;
const hasSelectionValue = hasSelection || (valueProvinces ?? []).length > 0;
const handleClearCoverage = (event: ReactMouseEvent) => {
event.stopPropagation();
event.preventDefault();
setSelection({});
setActiveProvince(null);
hydratedCoverageRef.current = "";
onChange({ provinces: [], cities: [], items: [] });
};
const formatSelected = () => {
if (!hasSelection && valueProvinces.length > 0) {
const provinceLabels = valueProvinces.map(
(province) => provinceOptions.find((option) => option.value === province)?.label ?? province,
);
const cityCount = (value?.cities ?? []).filter(Boolean).length;
return cityCount > 0 ? `${provinceLabels.join("、")}(共${cityCount}个市)` : provinceLabels.join("、");
}
const parts: string[] = [];
for (const option of provinceOptions) {
const provinceCities = selection[option.value];
if (provinceCities === undefined) {
continue;
}
const provinceLabel = option.label || option.value;
if (provinceCities.length === 0) {
parts.push(provinceLabel);
continue;
}
const cityLabels = provinceCities
.map((cityValue) => (cityCache[option.value] ?? []).find((item) => item.value === cityValue)?.label ?? cityValue)
.filter(Boolean);
parts.push(`${provinceLabel}${cityLabels.join("、")}`);
}
return parts.length > 0 ? parts.join("") : "请选择覆盖地市(可多选)";
};
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700 min-h-[42px]",
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"
: "",
)}
>
<span className={hasSelection || valueProvinces.length > 0 ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
{formatSelected()}
</span>
<span className="ml-2 flex shrink-0 items-center gap-1">
{hasSelectionValue ? (
<button
type="button"
onClick={handleClearCoverage}
aria-label="清除覆盖地市"
title="清除覆盖地市"
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
</span>
</button>
{open ? (
<div
ref={panelRef}
className="absolute left-0 top-full z-50 mt-2 w-[30rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex max-h-80">
<div className="w-1/2 space-y-1 overflow-y-auto border-r border-slate-100 p-2 dark:border-slate-800">
<p className="px-3 pb-1 text-xs font-semibold text-slate-400"></p>
{provinceOptions.map((option) => {
const isSelected = option.value in selection;
return (
<button
key={option.value}
type="button"
onClick={() => toggleProvince(option.value)}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors",
activeProvince === option.value
? "bg-violet-500/10 text-violet-700 dark:text-violet-200"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
)}
>
<span className="flex items-center gap-2">
<span className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "border-violet-600 bg-violet-600 text-white" : "border-slate-300 dark:border-slate-600",
)}>
{isSelected ? <Check className="h-3 w-3" /> : null}
</span>
<span className="break-anywhere">{option.label || option.value}</span>
</span>
{(selection[option.value]?.length ?? 0) > 0 ? (
<span className="crm-field-note ml-2 shrink-0">{selection[option.value].length}</span>
) : null}
</button>
);
})}
</div>
<div className="w-1/2 space-y-1 overflow-y-auto p-2">
<p className="px-3 pb-1 text-xs font-semibold text-slate-400"></p>
{loadingKeys[activeProvince] ? (
<div className="px-3 py-6 text-center text-sm text-slate-400"></div>
) : !activeProvince ? (
<div className="px-3 py-6 text-center text-sm text-slate-400"></div>
) : (cityCache[activeProvince] ?? []).length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-slate-400"></div>
) : (
(cityCache[activeProvince] ?? []).map((option) => {
const isSelected = (selection[activeProvince] ?? []).includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleCity(option.value)}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors",
isSelected
? "bg-violet-600 text-white"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
)}
>
<span className="break-anywhere">{option.label || option.value}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
);
})
)}
</div>
</div>
</div>
) : null}
</div>
);
}
function ExpansionExportFilterModal({
activeTab,
initialFilters,
exporting,
exportError,
officeOptions,
industryOptions,
provinceOptions,
certificationLevelOptions,
channelAttributeOptions,
relatedProjectStageOptions,
isOptions,
onClose,
onConfirm,
}: {
activeTab: ExpansionTab;
initialFilters: ExpansionExportFilters;
exporting: boolean;
exportError: string;
officeOptions: ExpansionDictOption[];
industryOptions: ExpansionDictOption[];
provinceOptions: ExpansionDictOption[];
certificationLevelOptions: ExpansionDictOption[];
channelAttributeOptions: ExpansionDictOption[];
relatedProjectStageOptions: ExpansionDictOption[];
isOptions: ExpansionDictOption[];
onClose: () => void;
onConfirm: (filters: ExpansionExportFilters) => void;
}) {
const normalizedInitialFilters = applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions);
const [draftFilters, setDraftFilters] = useState<ExpansionExportFilters>(normalizedInitialFilters);
const isSalesTab = activeTab === "sales";
const isCrmTab = activeTab === "crm";
const selectedSalesFields = resolveSelectedExpansionFields(draftFilters.selectedSalesFields, defaultSalesExportFields);
const selectedChannelFields = resolveSelectedExpansionFields(draftFilters.selectedChannelFields, defaultChannelExportFields);
const selectedCrmFields = resolveSelectedExpansionFields(draftFilters.selectedCrmFields, defaultCrmExportFields);
const crmExportColumns = buildCrmExportColumns(officeOptions);
const activeFieldOptions = isSalesTab ? salesExportColumns : isCrmTab ? crmExportColumns : buildChannelExportColumns(isOptions);
const selectedFieldKeys = isSalesTab ? selectedSalesFields : isCrmTab ? selectedCrmFields : 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)
: isCrmTab
? JSON.stringify(selectedCrmFields) !== JSON.stringify(defaultCrmExportFields)
: 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;
}
if (isCrmTab) {
setDraftFilters((current) => {
const currentFields = resolveSelectedExpansionFields(current.selectedCrmFields, defaultCrmExportFields);
const nextFields = currentFields.includes(fieldKey as CrmExportFieldKey)
? currentFields.filter((item) => item !== fieldKey)
: [...currentFields, fieldKey as CrmExportFieldKey];
return { ...current, selectedCrmFields: 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 ? <option key={value} value={value}>{value}</option> : 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 (
<ModalShell
title={`导出${isSalesTab ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展"}记录`}
subtitle="选择条件后导出 Excel不填条件则导出全部可见权限范围内的数据。"
onClose={onClose}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<button
type="button"
onClick={() => setDraftFilters((current) => ({
...applyDefaultRelatedProjectStageFilters({}, relatedProjectStageOptions),
selectedSalesFields: resolveSelectedExpansionFields(current.selectedSalesFields, defaultSalesExportFields),
selectedChannelFields: resolveSelectedExpansionFields(current.selectedChannelFields, defaultChannelExportFields),
selectedCrmFields: resolveSelectedExpansionFields(current.selectedCrmFields, defaultCrmExportFields),
}))}
disabled={!hasDraftFilters}
className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={() => onConfirm(draftFilters)}
disabled={exporting || !hasSelectedFields}
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{exporting ? "导出中..." : "确认导出"}
</button>
</div>
)}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
value={draftFilters.keyword ?? ""}
onChange={(event) => handleFilterChange("keyword", event.target.value)}
placeholder={isSalesTab ? "搜索姓名、工号、电话、行业、项目" : isCrmTab ? "搜索最终用户、进货商、对接人、联系人、跟进记录" : "搜索渠道、联系人、地区、项目"}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
{!isCrmTab ? (
<>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.intent ?? ""}
options={[
{ value: "", label: "全部合作意向" },
{ value: "高", label: "高意向" },
{ value: "中", label: "中意向" },
{ value: "低", label: "低意向" },
]}
placeholder="全部合作意向"
sheetTitle="选择合作意向"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("intent", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.hasRelatedProject ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "yes", label: "有关联项目" },
{ value: "no", label: "无关联项目" },
]}
placeholder="全部"
sheetTitle="选择关联项目"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("hasRelatedProject", value)}
/>
</label>
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300"></p>
<p className="text-xs text-slate-500 dark:text-slate-400">/</p>
</div>
{relatedProjectStageFilterOptions.length > 0 ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDraftFilters((current) => ({ ...current, relatedProjectStageCodes: defaultRelatedProjectStageCodes }))}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
</button>
<button
type="button"
onClick={() => setDraftFilters((current) => ({ ...current, relatedProjectStageCodes: relatedProjectStageFilterOptions.map((option) => option.value) }))}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
</button>
<button
type="button"
onClick={() => setDraftFilters((current) => ({ ...current, relatedProjectStageCodes: [] }))}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
</button>
</div>
) : null}
</div>
{relatedProjectStageFilterOptions.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{relatedProjectStageFilterOptions.map((option) => {
const checked = selectedRelatedProjectStageCodes.includes(option.value);
return (
<label key={option.value} className={cn(
"flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-colors",
checked
? "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
: "border-slate-200 bg-white text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
)}>
<input
type="checkbox"
checked={checked}
onChange={() => handleRelatedProjectStageToggle(option.value)}
className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
<span>{option.label}</span>
</label>
);
})}
</div>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
)}
</div>
</>
) : null}
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300"></p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDraftFilters((current) => ({
...current,
selectedSalesFields: isSalesTab ? salesExportColumns.map((column) => column.key) : current.selectedSalesFields,
selectedChannelFields: isSalesTab ? current.selectedChannelFields : isCrmTab ? current.selectedChannelFields : buildChannelExportColumns(isOptions).map((column) => column.key),
selectedCrmFields: isCrmTab ? crmExportColumns.map((column) => column.key) : current.selectedCrmFields,
}))}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
</button>
<button
type="button"
onClick={() => setDraftFilters((current) => ({
...current,
selectedSalesFields: isSalesTab ? defaultSalesExportFields : current.selectedSalesFields,
selectedChannelFields: isSalesTab ? current.selectedChannelFields : isCrmTab ? current.selectedChannelFields : defaultChannelExportFields,
selectedCrmFields: isCrmTab ? defaultCrmExportFields : current.selectedCrmFields,
}))}
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{activeFieldOptions.map((field) => {
const checked = selectedFieldKeys.includes(field.key);
return (
<label key={field.key} className={cn(
"flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-colors",
checked
? "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
: "border-slate-200 bg-white text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
)}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleField(field.key)}
className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
<span>{field.label}</span>
</label>
);
})}
</div>
{!hasSelectedFields ? <p className="text-xs text-rose-500"></p> : null}
</div>
{isSalesTab ? (
<>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / </span>
<AdaptiveSelect
value={draftFilters.officeName ?? ""}
options={toSearchableOptions(officeOptions, "全部代表处 / 办事处")}
placeholder="全部代表处 / 办事处"
sheetTitle="选择代表处 / 办事处"
searchable
searchPlaceholder="搜索代表处 / 办事处"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("officeName", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.industry ?? ""}
options={toSearchableOptions(industryOptions, "全部行业")}
placeholder="全部行业"
sheetTitle="选择所属行业"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("industry", value)}
/>
</label>
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.employmentStatus ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "active", label: "在职" },
{ value: "inactive", label: "离职" },
]}
placeholder="全部"
sheetTitle="选择在职状态"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("employmentStatus", value)}
/>
</label>
</>
) : isCrmTab ? (
<>
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.officeName ?? ""}
options={toSearchableOptions(officeOptions, "全部代表处")}
placeholder="全部代表处"
sheetTitle="选择代表处"
searchable
searchPlaceholder="搜索代表处"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("officeName", value)}
/>
</label>
</>
) : (
<>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.province ?? ""}
options={toSearchableOptions(provinceOptions, "全部省份")}
placeholder="全部省份"
sheetTitle="选择省份"
searchable
searchPlaceholder="搜索省份"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("province", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.certificationLevel ?? ""}
options={toSearchableOptions(certificationLevelOptions, "全部汇智内部认证级别")}
placeholder="全部汇智内部认证级别"
sheetTitle="选择汇智内部认证级别"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("certificationLevel", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.channelIndustry ?? ""}
options={toSearchableOptions(industryOptions, "全部行业")}
placeholder="全部行业"
sheetTitle="选择聚焦行业"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("channelIndustry", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.channelAttribute ?? ""}
options={toSearchableOptions(channelAttributeOptions, "全部渠道属性")}
placeholder="全部渠道属性"
sheetTitle="选择渠道属性"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("channelAttribute", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.establishedStartDate ?? ""}
onChange={(event) => handleFilterChange("establishedStartDate", event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.establishedEndDate ?? ""}
onChange={(event) => handleFilterChange("establishedEndDate", event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
</>
)}
</div>
{exportError ? <div className="crm-alert crm-alert-error mt-4">{exportError}</div> : null}
</ModalShell>
);
}
export default function Expansion() {
const currentUserId = getStoredCurrentUserId();
const location = useLocation();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
const [selectedItem, setSelectedItem] = useState<ExpansionItem | null>(null);
const [keyword, setKeyword] = useState("");
const [salesData, setSalesData] = useState<SalesExpansionItem[]>([]);
const [channelData, setChannelData] = useState<ChannelExpansionItem[]>([]);
const [crmData, setCrmData] = useState<CrmExpansionItem[]>([]);
const [supplierChannelOptions, setSupplierChannelOptions] = useState<ChannelExpansionItem[]>([]);
const [supplierSalesOptions, setSupplierSalesOptions] = useState<SalesExpansionItem[]>([]);
const [supplierChannelQuery, setSupplierChannelQuery] = useState("");
const [supplierSalesQuery, setSupplierSalesQuery] = useState("");
const [loadingSupplierChannelOptions, setLoadingSupplierChannelOptions] = useState(false);
const [loadingSupplierSalesOptions, setLoadingSupplierSalesOptions] = useState(false);
const [supplierRefreshTick, setSupplierRefreshTick] = useState(0);
const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadingMoreRef = useRef(false);
const loadedTabKeysRef = useRef(new Set<string>());
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState("");
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
const [industryOptions, setIndustryOptions] = useState<ExpansionDictOption[]>([]);
const [provinceOptions, setProvinceOptions] = useState<ExpansionDictOption[]>([]);
const [certificationLevelOptions, setCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
const [createCityOptions, setCreateCityOptions] = useState<ExpansionDictOption[]>([]);
const [editCityOptions, setEditCityOptions] = useState<ExpansionDictOption[]>([]);
const [createCoverageCityOptions, setCreateCoverageCityOptions] = useState<ExpansionDictOption[]>([]);
const [editCoverageCityOptions, setEditCoverageCityOptions] = useState<ExpansionDictOption[]>([]);
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [extensionTypeOptions, setExtensionTypeOptions] = useState<ExpansionDictOption[]>([]);
const [onlineStatusOptions, setOnlineStatusOptions] = useState<ExpansionDictOption[]>([]);
const [isOptions, setIsOptions] = useState<ExpansionDictOption[]>([]);
const [relatedProjectStageOptions, setRelatedProjectStageOptions] = useState<ExpansionDictOption[]>([]);
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<ExpansionExportFilters>(() => loadExpansionExportPreferences());
const [permissionCodes, setPermissionCodes] = useState<string[] | null>(null);
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 [crmDuplicateMessage, setCrmDuplicateMessage] = useState("");
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
const [salesEditFieldErrors, setSalesEditFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
const [channelEditFieldErrors, setChannelEditFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
const [invalidCreateChannelContactRows, setInvalidCreateChannelContactRows] = useState<number[]>([]);
const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState<number[]>([]);
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
const [crmDetailTab, setCrmDetailTab] = useState<"contacts" | "followups">("contacts");
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const [salesForm, setSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const [crmForm, setCrmForm] = useState<CreateCrmExpansionPayload>({
endUser: "",
officeName: "",
industryAttr: [],
extensionType: [],
purchaseDate: "",
warrantyExpiry: "",
onlineStatus: "",
contacts: [createEmptyCrmContact()],
supplierId: 0,
supplierName: "",
h3cContactId: 0,
h3cContactName: "",
hasExpansionOpportunity: "",
softwarePoints: undefined,
expansionTime: "",
expansionScale: "",
hasMaintenanceOpportunity: "",
});
const [editSalesForm, setEditSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const [editCrmForm, setEditCrmForm] = useState<CreateCrmExpansionPayload>({
endUser: "",
officeName: "",
industryAttr: [],
extensionType: [],
purchaseDate: "",
warrantyExpiry: "",
onlineStatus: "",
contacts: [createEmptyCrmContact()],
supplierId: 0,
supplierName: "",
h3cContactId: 0,
h3cContactName: "",
hasExpansionOpportunity: "",
softwarePoints: undefined,
expansionTime: "",
expansionScale: "",
hasMaintenanceOpportunity: "",
});
const [crmFieldErrors, setCrmFieldErrors] = useState<Partial<Record<keyof CreateCrmExpansionPayload, string>>>({});
const [editCrmFieldErrors, setEditCrmFieldErrors] = useState<Partial<Record<keyof CreateCrmExpansionPayload, string>>>({});
const [invalidCreateCrmContactRows, setInvalidCreateCrmContactRows] = useState<number[]>([]);
const [invalidEditCrmContactRows, setInvalidEditCrmContactRows] = useState<number[]>([]);
const [moveOpen, setMoveOpen] = useState(false);
const [moveSubmitting, setMoveSubmitting] = useState(false);
const [moveError, setMoveError] = useState("");
const [moveChannelForm, setMoveChannelForm] = useState<MoveChannelToCrmPayload>(defaultMoveChannelForm);
const [moveCrmForm, setMoveCrmForm] = useState<MoveCrmToChannelPayload>(defaultMoveCrmForm);
const [moveChannelFieldErrors, setMoveChannelFieldErrors] = useState<Partial<Record<keyof MoveChannelToCrmPayload, string>>>({});
const [moveCrmFieldErrors, setMoveCrmFieldErrors] = useState<Partial<Record<keyof MoveCrmToChannelPayload, string>>>({});
const [invalidMoveCrmContactRows, setInvalidMoveCrmContactRows] = useState<number[]>([]);
const [invalidMoveChannelContactRows, setInvalidMoveChannelContactRows] = useState<number[]>([]);
const [moveCityOptions, setMoveCityOptions] = useState<ExpansionDictOption[]>([]);
const hasForegroundModal = createOpen || editOpen || exportFilterOpen || moveOpen;
const canCreateExpansion = permissionCodes !== null && canUsePermission(EXPANSION_CREATE_PERMISSION, permissionCodes);
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 ?? []);
setExtensionTypeOptions(data.extensionTypeOptions ?? []);
setOnlineStatusOptions(data.onlineStatusOptions ?? []);
setIsOptions(data.isOptions ?? []);
setNextChannelCode(data.nextChannelCode ?? "");
return data;
}, []);
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(async () => {
setLoadingSupplierChannelOptions(true);
try {
const data = await getOpportunityExpansionOptions({
keyword: supplierChannelQuery || undefined,
limit: SUPPLIER_SEARCH_LIMIT,
});
if (!cancelled) {
setSupplierChannelOptions(data.channelItems ?? []);
}
} catch {
if (!cancelled) {
setSupplierChannelOptions([]);
}
} finally {
if (!cancelled) {
setLoadingSupplierChannelOptions(false);
}
}
}, SUPPLIER_SEARCH_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [supplierChannelQuery, supplierRefreshTick]);
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(async () => {
setLoadingSupplierSalesOptions(true);
try {
const data = await getOpportunityExpansionOptions({
keyword: supplierSalesQuery || undefined,
limit: SUPPLIER_SEARCH_LIMIT,
});
if (!cancelled) {
setSupplierSalesOptions(data.salesItems ?? []);
}
} catch {
if (!cancelled) {
setSupplierSalesOptions([]);
}
} finally {
if (!cancelled) {
setLoadingSupplierSalesOptions(false);
}
}
}, SUPPLIER_SEARCH_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [supplierSalesQuery, supplierRefreshTick]);
useEffect(() => {
let cancelled = false;
async function loadPermissions() {
try {
const permissions = await listMyPermissions();
if (!cancelled) {
setPermissionCodes(
permissions
.filter((permission) => permission.status !== 0)
.map((permission) => permission.code)
.filter(Boolean),
);
}
} catch {
if (!cancelled) {
setPermissionCodes(null);
}
}
}
void loadPermissions();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
async function loadRelatedProjectStageDict() {
try {
const data = await getOpportunityMeta();
if (!cancelled) {
// 使用全量阶段(含已禁用字典项),保证销售/渠道导出弹窗与默认导出不遗漏相关商机
setRelatedProjectStageOptions(data.allStageOptions ?? 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 [];
}
}, []);
const loadCoverageCityOptions = useCallback(async (provinceNames: string[], isEdit = false) => {
const setter = isEdit ? setEditCoverageCityOptions : setCreateCoverageCityOptions;
const normalizedProvinces = Array.from(new Set((provinceNames ?? []).map((name) => name?.trim()).filter(Boolean)));
if (normalizedProvinces.length === 0) {
setter([]);
return [];
}
try {
const results = await Promise.all(normalizedProvinces.map((name) => getExpansionCityOptions(name).catch(() => [] as ExpansionDictOption[])));
const merged: ExpansionDictOption[] = [];
const seen = new Set<string>();
for (const options of results) {
for (const option of options ?? []) {
const value = option.value ?? "";
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
merged.push(option);
}
}
setter(merged);
return merged;
} catch {
setter([]);
return [];
}
}, []);
useEffect(() => {
const requestedTab = (location.state as ExpansionLocationState)?.tab;
if (requestedTab === "sales" || requestedTab === "channel" || requestedTab === "crm") {
setActiveTab(requestedTab);
}
}, [location.state]);
// 拉取含详情contacts/相关项目/跟进等)的结果,并替换当前选中项,
// 确保从任意入口(列表点击、跨页跳转、编辑等)进入详情都能展示完整数据。
const enrichSelectedItem = useCallback(async (item: ExpansionItem) => {
if (!item || !item.id) {
return;
}
try {
if (item.type === "crm") {
const data = await getCrmExpansionOverview(keyword, true);
const detailedItem = (data.crmItems ?? []).find((candidate) => candidate.id === item.id);
if (detailedItem) {
setSelectedItem((current) => current?.id === detailedItem.id ? detailedItem : current);
}
return;
}
const data = await getExpansionOverview(keyword, true);
const detailedItem = item.type === "sales"
? (data.salesItems ?? []).find((candidate) => candidate.id === item.id)
: (data.channelItems ?? []).find((candidate) => candidate.id === item.id);
if (detailedItem) {
setSelectedItem((current) => current?.id === detailedItem.id ? detailedItem : current);
}
} catch {
// 详情补齐失败时保留已有数据,不影响展示
}
}, [keyword]);
useEffect(() => {
const requestedState = location.state as ExpansionLocationState;
const requestedTab = requestedState?.tab;
const requestedId = requestedState?.selectedId;
if (!requestedId) {
return;
}
const selectAndEnrich = (match: ExpansionItem | undefined) => {
if (!match) {
return;
}
setSelectedItem(match);
void enrichSelectedItem(match);
};
if (requestedTab === "sales") {
selectAndEnrich(salesData.find((item) => item.id === requestedId));
return;
}
if (requestedTab === "channel") {
selectAndEnrich(channelData.find((item) => item.id === requestedId));
return;
}
if (requestedTab === "crm") {
selectAndEnrich(crmData.find((item) => item.id === requestedId));
return;
}
selectAndEnrich(salesData.find((item) => item.id === requestedId) ?? channelData.find((item) => item.id === requestedId) ?? crmData.find((item) => item.id === requestedId) ?? null);
}, [location.state, salesData, channelData, crmData, enrichSelectedItem]);
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;
const loadKey = `${activeTab}:${keyword}:${refreshTick}`;
async function loadExpansionData() {
try {
if (activeTab === "crm") {
if (!loadedTabKeysRef.current.has(loadKey)) {
const data = await getCrmExpansionOverview(keyword, false, LIST_PAGE_SIZE + 1);
if (cancelled) {
return;
}
setCrmData(dedupeExpansionItemsById(data.crmItems ?? []));
loadedTabKeysRef.current.add(loadKey);
}
setSelectedItem(null);
} else {
const salesKey = `sales:${keyword}:${refreshTick}`;
const channelKey = `channel:${keyword}:${refreshTick}`;
if (!loadedTabKeysRef.current.has(salesKey) && !loadedTabKeysRef.current.has(channelKey)) {
const data = await getExpansionOverview(keyword, false, LIST_PAGE_SIZE + 1);
if (cancelled) {
return;
}
setSalesData(dedupeExpansionItemsById(data.salesItems ?? []));
setChannelData(dedupeExpansionItemsById(data.channelItems ?? []));
loadedTabKeysRef.current.add(salesKey);
loadedTabKeysRef.current.add(channelKey);
}
setSelectedItem(null);
}
} catch {
if (!cancelled) {
if (activeTab === "crm") {
setCrmData([]);
} else {
setSalesData([]);
setChannelData([]);
}
setSelectedItem(null);
}
}
}
void loadExpansionData();
return () => {
cancelled = true;
};
}, [keyword, refreshTick, activeTab]);
useEffect(() => {
setVisibleItemCount(LIST_PAGE_SIZE);
}, [keyword, activeTab]);
const activeData = activeTab === "sales" ? salesData : activeTab === "channel" ? channelData : crmData;
const hasMoreItems = visibleItemCount < activeData.length;
const supplierChannelSearchOptions: SearchableOption[] = supplierChannelOptions.map((item) => ({
value: item.id,
label: item.name || `渠道#${item.id}`,
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
}));
const supplierSalesSearchOptions: SearchableOption[] = supplierSalesOptions.map((item) => ({
value: item.id,
label: [item.name, item.employeeNo, item.officeName].filter(Boolean).join(" - ") || `负责人#${item.id}`,
keywords: [item.employeeNo || "", item.officeName || "", item.phone || "", item.title || ""],
}));
const loadMoreItems = async () => {
if (!hasMoreItems || loadingMoreRef.current) {
return;
}
loadingMoreRef.current = true;
setLoadingMore(true);
setLoadMoreError("");
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
try {
if (activeTab === "crm") {
const data = await getCrmExpansionOverview(keyword, false, nextVisibleCount + 1);
setCrmData(dedupeExpansionItemsById(data.crmItems ?? []));
setVisibleItemCount(nextVisibleCount);
} else {
const data = await getExpansionOverview(keyword, false, nextVisibleCount + 1);
setSalesData(dedupeExpansionItemsById(data.salesItems ?? []));
setChannelData(dedupeExpansionItemsById(data.channelItems ?? []));
setVisibleItemCount(nextVisibleCount);
}
} catch {
setLoadMoreError("加载更多失败,请重试");
} finally {
loadingMoreRef.current = false;
setLoadingMore(false);
}
};
useEffect(() => {
if (!hasMoreItems) {
return;
}
const handleScroll = () => {
const loadMoreElement = loadMoreRef.current;
if (loadMoreElement && loadMoreElement.getBoundingClientRect().top <= window.innerHeight + 120) {
void loadMoreItems();
}
};
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [hasMoreItems, visibleItemCount, activeData.length, keyword, activeTab]);
const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? [];
const handleSelectItem = (item: ExpansionItem) => {
setSelectedItem(item);
if (item.type === "crm") {
setCrmDetailTab("contacts");
}
void enrichSelectedItem(item);
};
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 = <K extends keyof CreateSalesExpansionPayload>(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 = <K extends keyof CreateChannelExpansionPayload>(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 = <K extends keyof CreateSalesExpansionPayload>(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 = <K extends keyof CreateChannelExpansionPayload>(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 handleCrmChange = <K extends keyof CreateCrmExpansionPayload>(key: K, value: CreateCrmExpansionPayload[K]) => {
setCrmForm((current) => {
const next = { ...current, [key]: value };
if (key === "purchaseDate") {
next.warrantyExpiry = addYearsToDate(String(value ?? ""), 3);
}
return next;
});
if (key in crmFieldErrors) {
setCrmFieldErrors((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
};
const handleEditCrmChange = <K extends keyof CreateCrmExpansionPayload>(key: K, value: CreateCrmExpansionPayload[K]) => {
setEditCrmForm((current) => ({ ...current, [key]: value }));
if (key in editCrmFieldErrors) {
setEditCrmFieldErrors((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
};
const handleCrmContactChange = (index: number, key: keyof CrmExpansionContact, value: string, isEdit = false) => {
const setter = isEdit ? setEditCrmForm : setCrmForm;
setter((current) => {
const nextContacts = [...(current.contacts ?? [])];
const target = { ...(nextContacts[index] ?? createEmptyCrmContact()), [key]: value };
nextContacts[index] = target;
return { ...current, contacts: nextContacts };
});
if (isEdit) {
setEditCrmFieldErrors((current) => {
if (!current.contacts) {
return current;
}
const next = { ...current };
delete next.contacts;
return next;
});
setInvalidEditCrmContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
return;
}
setCrmFieldErrors((current) => {
if (!current.contacts) {
return current;
}
const next = { ...current };
delete next.contacts;
return next;
});
setInvalidCreateCrmContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
};
const addCrmContact = (isEdit = false) => {
const setter = isEdit ? setEditCrmForm : setCrmForm;
setter((current) => ({
...current,
contacts: [...(current.contacts ?? []), createEmptyCrmContact()],
}));
};
const removeCrmContact = (index: number, isEdit = false) => {
const setter = isEdit ? setEditCrmForm : setCrmForm;
setter((current) => {
const currentContacts = current.contacts ?? [];
const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index);
return {
...current,
contacts: nextContacts.length > 0 ? nextContacts : [createEmptyCrmContact()],
};
});
};
const handleMoveCrmContactChange = (index: number, key: keyof CrmExpansionContact, value: string) => {
setMoveChannelForm((current) => {
const nextContacts = [...(current.contacts ?? [])];
const target = { ...(nextContacts[index] ?? createEmptyCrmContact()), [key]: value };
nextContacts[index] = target;
return { ...current, contacts: nextContacts };
});
setMoveChannelFieldErrors((current) => {
if (!current.contacts) {
return current;
}
const next = { ...current };
delete next.contacts;
return next;
});
setInvalidMoveCrmContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
};
const addMoveCrmContact = () => {
setMoveChannelForm((current) => ({
...current,
contacts: [...(current.contacts ?? []), createEmptyCrmContact()],
}));
};
const removeMoveCrmContact = (index: number) => {
setMoveChannelForm((current) => {
const currentContacts = current.contacts ?? [];
const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index);
return {
...current,
contacts: nextContacts.length > 0 ? nextContacts : [createEmptyCrmContact()],
};
});
};
const handleMoveChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string) => {
setMoveCrmForm((current) => {
const nextContacts = [...(current.contacts ?? [])];
const target = { ...(nextContacts[index] ?? {}), [key]: value };
nextContacts[index] = target;
return { ...current, contacts: nextContacts };
});
setMoveCrmFieldErrors((current) => {
if (!current.contacts) {
return current;
}
const next = { ...current };
delete next.contacts;
return next;
});
setInvalidMoveChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
};
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] ?? {}), [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 resetCreateState = () => {
setCreateOpen(false);
setCreateError("");
setSalesDuplicateChecking(false);
setChannelDuplicateChecking(false);
setSalesDuplicateMessage("");
setChannelDuplicateMessage("");
setCrmDuplicateMessage("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setCrmFieldErrors({});
setInvalidCreateChannelContactRows([]);
setInvalidCreateCrmContactRows([]);
setSalesForm(defaultSalesForm);
setChannelForm(defaultChannelForm);
setCrmForm({
endUser: "",
officeName: "",
industryAttr: [],
extensionType: [],
purchaseDate: "",
warrantyExpiry: "",
onlineStatus: "",
contacts: [createEmptyCrmContact()],
supplierId: 0,
supplierName: "",
h3cContactId: 0,
h3cContactName: "",
hasExpansionOpportunity: "",
softwarePoints: undefined,
expansionTime: "",
expansionScale: "",
hasMaintenanceOpportunity: "",
});
setCreateCityOptions([]);
setCreateCoverageCityOptions([]);
};
const resetEditState = () => {
setEditOpen(false);
setEditError("");
setSalesEditFieldErrors({});
setChannelEditFieldErrors({});
setEditCrmFieldErrors({});
setInvalidEditChannelContactRows([]);
setInvalidEditCrmContactRows([]);
setEditSalesForm(defaultSalesForm);
setEditChannelForm(defaultChannelForm);
setEditCrmForm({
endUser: "",
officeName: "",
industryAttr: [],
extensionType: [],
purchaseDate: "",
warrantyExpiry: "",
onlineStatus: "",
contacts: [createEmptyCrmContact()],
supplierId: 0,
supplierName: "",
h3cContactId: 0,
h3cContactName: "",
hasExpansionOpportunity: "",
softwarePoints: undefined,
expansionTime: "",
expansionScale: "",
hasMaintenanceOpportunity: "",
});
setEditCityOptions([]);
setEditCoverageCityOptions([]);
};
const handleOpenCreate = async () => {
setCreateError("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setCrmFieldErrors({});
setInvalidCreateChannelContactRows([]);
setInvalidCreateCrmContactRows([]);
try {
await loadMeta();
} catch {}
setCreateCityOptions([]);
setCreateCoverageCityOptions([]);
setSupplierRefreshTick((current) => current + 1);
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",
regionProvince: decodeExpansionMultiValue(selectedItem.regionProvince).values,
regionCity: decodeExpansionMultiValue(selectedItem.regionCity).values,
regionItems: parseCoverageItemsString(selectedItem.regionItems),
});
} else if (selectedItem.type === "crm") {
setEditCrmFieldErrors({});
setInvalidEditCrmContactRows([]);
setEditCrmForm({
endUser: selectedItem.endUser ?? "",
officeName: selectedItem.officeName ?? "",
industryAttr: normalizeMultiOptionValues(
selectedItem.industryAttrCode ?? (selectedItem.industryAttr === "无" ? "" : selectedItem.industryAttr),
industryOptions,
),
extensionType: normalizeMultiOptionValues(
selectedItem.extensionType ?? "",
extensionTypeOptions,
),
purchaseDate: selectedItem.purchaseDate ?? "",
warrantyExpiry: selectedItem.warrantyExpiry ?? "",
onlineStatus: selectedItem.onlineStatus ?? "",
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 ?? "",
}))
: [createEmptyCrmContact()],
supplierId: selectedItem.supplierId ?? 0,
supplierName: selectedItem.supplierName ?? "",
h3cContactId: selectedItem.h3cContactId ?? 0,
h3cContactName: selectedItem.h3cContactName ?? "",
hasExpansionOpportunity: selectedItem.hasExpansionOpportunity ?? "",
softwarePoints: selectedItem.softwarePoints,
expansionTime: selectedItem.expansionTime ?? "",
expansionScale: selectedItem.expansionScale ?? "",
hasMaintenanceOpportunity: selectedItem.hasMaintenanceOpportunity ?? "",
});
} else {
// 列表条目通过 includeDetails=false 获取,不含 contacts若当前选中项缺少联系人
// 拉取包含详情的结果补齐,避免编辑时人员信息丢失(或保存时被清空)。
let sourceItem = selectedItem;
if ((selectedItem.contacts?.length ?? 0) === 0) {
try {
const data = await getExpansionOverview(keyword, true);
const detailedItem = (data.channelItems ?? []).find((candidate) => candidate.id === selectedItem.id);
if (detailedItem) {
sourceItem = detailedItem;
setSelectedItem((current) => current?.id === detailedItem.id ? detailedItem : current);
}
} catch {}
}
const parsedChannelAttributes = decodeExpansionMultiValue(sourceItem.channelAttributeCode);
const parsedInternalAttributes = decodeExpansionMultiValue(sourceItem.internalAttributeCode);
const parsedCoverageProvinces = decodeExpansionMultiValue(sourceItem.coverageProvince).values;
const normalizedProvinceName = sourceItem.province === "无" ? "" : sourceItem.province ?? "";
const normalizedCityName = sourceItem.city === "无" ? "" : sourceItem.city ?? "";
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
setEditChannelForm({
channelCode: sourceItem.channelCode ?? "",
channelName: sourceItem.name ?? "",
province: normalizedProvinceName,
city: normalizedCityName,
coverageProvince: parsedCoverageProvinces,
coverageCity: decodeExpansionMultiValue(sourceItem.coverageCity).values,
coverageItems: parseCoverageItemsString(sourceItem.coverageItems),
officeAddress: sourceItem.officeAddress === "无" ? "" : sourceItem.officeAddress ?? "",
channelIndustry: normalizeMultiOptionValues(
sourceItem.channelIndustryCode ?? (sourceItem.channelIndustry === "无" ? "" : sourceItem.channelIndustry),
latestIndustryOptions,
),
certificationLevel: normalizeOptionValue(
sourceItem.certificationLevel === "无" ? "" : sourceItem.certificationLevel,
latestCertificationLevelOptions,
) || "",
annualRevenue: sourceItem.annualRevenue ? Number(sourceItem.annualRevenue) : undefined,
staffSize: sourceItem.size ?? undefined,
registeredCapital: sourceItem.registeredCapital ? Number(sourceItem.registeredCapital) : undefined,
contactEstablishedDate: sourceItem.establishedDate === "无" ? "" : sourceItem.establishedDate ?? "",
intentLevel: sourceItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(sourceItem.hasDesktopExp),
channelAttribute: parsedChannelAttributes.values,
channelAttributeCustom: parsedChannelAttributes.customText,
internalAttribute: parsedInternalAttributes.values,
stage: sourceItem.stageCode ?? "initial_contact",
remark: sourceItem.notes === "无" ? "" : sourceItem.notes ?? "",
contacts: buildChannelContactRows(sourceItem.contacts),
});
void loadCityOptions(normalizedProvinceName, true);
void loadCoverageCityOptions(parsedCoverageProvinces, true);
}
setSupplierRefreshTick((current) => current + 1);
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 if (activeTab === "crm") {
const { errors: validationErrors, invalidContactRows } = validateCrmForm(crmForm);
if (Object.keys(validationErrors).length > 0) {
setCrmFieldErrors(validationErrors);
setInvalidCreateCrmContactRows(invalidContactRows);
setCreateError("请先完整填写CRM拓展必填字段");
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 if (activeTab === "channel") {
const duplicateResult = await checkChannelExpansionDuplicate(channelForm.channelName.trim());
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
setChannelDuplicateMessage(duplicateMessage);
setChannelCreateFieldErrors((current) => ({ ...current, channelName: duplicateMessage }));
setCreateError(duplicateMessage);
return;
}
} else if (activeTab === "crm") {
const duplicateResult = await checkCrmExpansionDuplicate(crmForm.endUser.trim());
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "最终用户重复,请确认该客户是否已存在!";
setCrmDuplicateMessage(duplicateMessage);
setCrmFieldErrors((current) => ({ ...current, endUser: duplicateMessage }));
setCreateError(duplicateMessage);
return;
}
}
setSubmitting(true);
try {
if (activeTab === "sales") {
await createSalesExpansion(normalizeSalesPayload(salesForm));
} else if (activeTab === "crm") {
await createCrmExpansion(crmForm);
} 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 if (selectedItem.type === "crm") {
const { errors: validationErrors, invalidContactRows } = validateCrmForm(editCrmForm);
if (Object.keys(validationErrors).length > 0) {
setEditCrmFieldErrors(validationErrors);
setInvalidEditCrmContactRows(invalidContactRows);
setEditError("请先完整填写CRM拓展必填字段");
return;
}
} else {
const { errors: validationErrors, invalidContactRows } = validateChannelForm(editChannelForm, channelOtherOptionValue);
if (Object.keys(validationErrors).length > 0) {
setChannelEditFieldErrors(validationErrors);
setInvalidEditChannelContactRows(invalidContactRows);
setEditError("请先完整填写渠道拓展必填字段");
return;
}
}
if (selectedItem.type === "sales") {
const duplicateResult = await checkSalesExpansionDuplicate(editSalesForm.employeeNo.trim(), selectedItem.id);
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!";
setSalesDuplicateMessage(duplicateMessage);
setSalesEditFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage }));
setEditError(duplicateMessage);
return;
}
} else if (selectedItem.type === "channel") {
const duplicateResult = await checkChannelExpansionDuplicate(editChannelForm.channelName.trim(), selectedItem.id);
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
setChannelDuplicateMessage(duplicateMessage);
setChannelEditFieldErrors((current) => ({ ...current, channelName: duplicateMessage }));
setEditError(duplicateMessage);
return;
}
} else if (selectedItem.type === "crm") {
const duplicateResult = await checkCrmExpansionDuplicate(editCrmForm.endUser.trim(), selectedItem.id);
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "最终用户重复,请确认该客户是否已存在!";
setCrmDuplicateMessage(duplicateMessage);
setEditCrmFieldErrors((current) => ({ ...current, endUser: duplicateMessage }));
setEditError(duplicateMessage);
return;
}
}
setSubmitting(true);
try {
if (selectedItem.type === "sales") {
await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm));
} else if (selectedItem.type === "crm") {
await updateCrmExpansion(selectedItem.id, editCrmForm);
} 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 resetMoveState = () => {
setMoveOpen(false);
setMoveError("");
setMoveChannelFieldErrors({});
setMoveCrmFieldErrors({});
setMoveChannelForm(defaultMoveChannelForm);
setMoveCrmForm(defaultMoveCrmForm);
setMoveCityOptions([]);
setInvalidMoveCrmContactRows([]);
setInvalidMoveChannelContactRows([]);
setSupplierChannelQuery("");
setSupplierSalesQuery("");
};
const handleOpenMove = async () => {
if (!selectedItem) {
return;
}
if (!canEditSelectedItem) {
return;
}
setMoveError("");
setMoveChannelFieldErrors({});
setMoveCrmFieldErrors({});
setSupplierRefreshTick((current) => current + 1);
if (selectedItem.type === "channel") {
// 采购时间/过保时间不沿渠道「成立时间」强预填,留空让用户手选,避免实际不符
setMoveChannelForm({
officeName: matchOfficeValueByProvince(selectedItem.province, officeOptions),
extensionType: [],
purchaseDate: "",
warrantyExpiry: "",
onlineStatus: "",
supplierId: 0,
supplierName: "",
h3cContactId: 0,
h3cContactName: "",
hasExpansionOpportunity: "",
softwarePoints: undefined,
expansionTime: "",
expansionScale: "",
hasMaintenanceOpportunity: "",
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 ?? "",
}))
: [],
});
setMoveCityOptions([]);
} else if (selectedItem.type === "crm") {
const initialProvince = officeOptions.find((option) => option.value === selectedItem.officeName)?.label ?? selectedItem.officeName ?? "";
setMoveCrmForm({
province: initialProvince,
city: "",
officeAddress: "",
certificationLevel: "",
annualRevenue: 0,
staffSize: 0,
registeredCapital: 0,
channelAttribute: [],
channelAttributeCustom: "",
internalAttribute: [],
coverageProvince: [],
coverageCity: [],
intentLevel: "",
hasDesktopExp: false,
stage: "initial_contact",
landedFlag: false,
expectedSignDate: "",
contacts: buildChannelContactRows(selectedItem.contacts),
});
if (initialProvince) {
try {
setMoveCityOptions(await getExpansionCityOptions(initialProvince));
} catch {
setMoveCityOptions([]);
}
} else {
setMoveCityOptions([]);
}
}
setMoveOpen(true);
};
const validateMoveChannelForm = (form: MoveChannelToCrmPayload, sourceId: CrmId) => {
const errors: Partial<Record<keyof MoveChannelToCrmPayload, string>> = {};
const invalidContactRows: number[] = [];
if (!form.officeName?.trim()) errors.officeName = "请选择代表处";
if ((form.extensionType?.length ?? 0) <= 0) errors.extensionType = "请选择类型";
if (form.softwarePoints == null || Number.isNaN(form.softwarePoints) || form.softwarePoints < 0) errors.softwarePoints = "请填写软件点数";
if (!form.purchaseDate) errors.purchaseDate = "请选择采购时间";
if (!form.warrantyExpiry) errors.warrantyExpiry = "请选择过保时间";
if (!form.onlineStatus?.trim()) errors.onlineStatus = "请选择在线情况";
if ((!form.supplierId || String(form.supplierId) === "0") && !form.supplierName?.trim()) errors.supplierId = "请选择或填写进货商";
else if (String(form.supplierId) === String(sourceId)) errors.supplierId = "不能选择被迁移的渠道本身";
if ((!form.h3cContactId || String(form.h3cContactId) === "0") && !form.h3cContactName?.trim()) errors.h3cContactId = "请选择或填写新华三对接人";
if (!form.hasExpansionOpportunity?.trim()) errors.hasExpansionOpportunity = "请选择是否有扩容机会";
if (form.hasExpansionOpportunity === "1") {
if (!form.expansionTime?.trim()) errors.expansionTime = "请选择扩容时间";
if (!form.expansionScale?.trim()) errors.expansionScale = "请填写扩容规模";
}
if (!form.hasMaintenanceOpportunity?.trim()) errors.hasMaintenanceOpportunity = "请选择是否有维保项目机会";
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 };
};
const validateMoveCrmForm = (form: MoveCrmToChannelPayload) => {
const errors: Partial<Record<keyof MoveCrmToChannelPayload, string>> = {};
if (!form.city?.trim()) errors.city = "请选择市";
if (!form.officeAddress?.trim()) errors.officeAddress = "请填写办公地址";
if (!form.certificationLevel?.trim()) errors.certificationLevel = "请选择汇智内部认证级别";
if (!(form.annualRevenue > 0)) errors.annualRevenue = "请填写年度营业额";
if (!(form.staffSize > 0)) errors.staffSize = "请填写人员规模";
if (!(form.registeredCapital > 0)) errors.registeredCapital = "请填写注册资金(万元)";
if (!form.channelAttribute?.length) errors.channelAttribute = "请选择渠道属性";
if (!form.internalAttribute?.length) errors.internalAttribute = "请选择新华三内部属性";
if (!form.coverageProvince?.length) errors.coverageProvince = "请选择覆盖省份";
if (!form.coverageCity?.length) errors.coverageCity = "请选择覆盖市/区/县";
if (!form.intentLevel?.trim()) errors.intentLevel = "请选择合作意向";
const contactValidation = validateChannelContactRows(form.contacts);
if (contactValidation.error) {
errors.contacts = contactValidation.error;
}
return { errors, invalidContactRows: contactValidation.invalidContactRows };
};
const handleMoveSubmit = async () => {
if (!selectedItem || moveSubmitting) {
return;
}
if (!canEditSelectedItem) {
setMoveError("仅可操作本人创建的数据");
return;
}
setMoveError("");
if (selectedItem.type === "channel") {
const { errors: validationErrors, invalidContactRows } = validateMoveChannelForm(moveChannelForm, selectedItem.id);
if (Object.keys(validationErrors).length > 0) {
setMoveChannelFieldErrors(validationErrors);
setInvalidMoveCrmContactRows(invalidContactRows);
setMoveError("请先完整填写必填字段");
return;
}
} else if (selectedItem.type === "crm") {
const { errors: validationErrors, invalidContactRows } = validateMoveCrmForm(moveCrmForm);
if (Object.keys(validationErrors).length > 0) {
setMoveCrmFieldErrors(validationErrors);
setInvalidMoveChannelContactRows(invalidContactRows);
setMoveError("请先完整填写必填字段");
return;
}
} else {
return;
}
setMoveSubmitting(true);
try {
if (selectedItem.type === "channel") {
await moveChannelToCrm(selectedItem.id, moveChannelForm);
} else {
await moveCrmToChannel(selectedItem.id, moveCrmForm);
}
resetMoveState();
setSelectedItem(null);
setRefreshTick((current) => current + 1);
} catch (error) {
setMoveError(error instanceof Error ? error.message : "迁移失败");
} finally {
setMoveSubmitting(false);
}
};
const renderEmpty = () => (
<div className="crm-empty-panel">
</div>
);
const renderFollowUpTimeline = () => {
if (followUpRecords.length <= 0) {
return (
<div className="crm-empty-panel">
</div>
);
}
return (
<div className="relative space-y-6 border-l-2 border-slate-100 pl-4 dark:border-slate-800">
{followUpRecords.map((record) => {
const summary = getExpansionFollowUpSummary(record);
return (
<div key={record.id} className="relative">
<div className="absolute -left-[21px] mt-1.5 h-2.5 w-2.5 rounded-full bg-violet-500 ring-4 ring-white dark:ring-slate-900" />
<div className="rounded-xl border border-slate-100 bg-slate-50/50 p-4 dark:border-slate-800 dark:bg-slate-800/20">
{selectedItem?.type === "crm" ? (
<div className="grid grid-cols-1 gap-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-500/20 dark:bg-amber-500/10 sm:grid-cols-2">
<div><p className="mb-1 text-xs text-amber-700 dark:text-amber-300">访</p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{summary.visitStartTime}</p></div>
<div><p className="mb-1 text-xs text-amber-700 dark:text-amber-300">访</p><p className="break-anywhere whitespace-pre-line font-medium text-slate-900 dark:text-white">{record.content || "无"}</p></div>
</div>
) : (
<div className="grid grid-cols-1 gap-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-500/20 dark:bg-amber-500/10 sm:grid-cols-3">
<div><p className="mb-1 text-xs text-amber-700 dark:text-amber-300">访</p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{summary.visitStartTime}</p></div>
<div><p className="mb-1 text-xs text-amber-700 dark:text-amber-300"></p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{summary.evaluationContent}</p></div>
<div><p className="mb-1 text-xs text-amber-700 dark:text-amber-300"></p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{summary.nextPlan}</p></div>
</div>
)}
<p className="mt-2 text-xs text-slate-400">: {record.user || "无"}<span className="ml-3">{record.date || "无"}</span></p>
</div>
</div>
)})}
</div>
);
};
const handleTabChange = (tab: ExpansionTab) => {
setActiveTab(tab);
setSelectedItem(null);
setExportError("");
};
const handleExport = async (filters: ExpansionExportFilters) => {
if (exporting) {
return;
}
const isSalesTab = activeTab === "sales";
const isCrmTab = activeTab === "crm";
const normalizedFilters = applyDefaultRelatedProjectStageFilters(filters, relatedProjectStageOptions);
setExporting(true);
setExportError("");
setExportFilters(normalizedFilters);
persistExpansionExportPreferences(normalizedFilters);
try {
const overview = !isCrmTab ? await getExpansionOverview("") : null;
const exportSalesItems = overview
? dedupeExpansionItemsById(overview.salesItems ?? [])
.map((item) => withFilteredSalesRelatedProjects(item, normalizedFilters))
.filter((item) => matchesSalesExportFilters(item, normalizedFilters))
: [];
const exportChannelItems = overview
? dedupeExpansionItemsById(overview.channelItems ?? [])
.map((item) => withFilteredChannelRelatedProjects(item, normalizedFilters))
.filter((item) => matchesChannelExportFilters(item, normalizedFilters))
: [];
const exportCrmItems = isCrmTab
? dedupeExpansionItemsById((await getCrmExpansionOverview("")).crmItems ?? [])
.filter((item) => matchesCrmExportFilters(item, normalizedFilters, officeOptions))
: [];
const exportItems = isSalesTab ? exportSalesItems : isCrmTab ? exportCrmItems : exportChannelItems;
const selectedSalesFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedSalesFields, defaultSalesExportFields);
const selectedChannelFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedChannelFields, defaultChannelExportFields);
const selectedCrmFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedCrmFields, defaultCrmExportFields);
const selectedFieldKeys = isSalesTab ? selectedSalesFieldKeys : isCrmTab ? selectedCrmFieldKeys : selectedChannelFieldKeys;
if (exportItems.length <= 0) {
throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展"}数据`);
}
if (selectedFieldKeys.length <= 0) {
throw new Error("请至少选择一个导出字段");
}
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展");
const salesColumns = salesExportColumns.filter((column) => selectedSalesFieldKeys.includes(column.key));
const channelColumns = buildChannelExportColumns(isOptions).filter((column) => selectedChannelFieldKeys.includes(column.key));
const crmColumns = buildCrmExportColumns(officeOptions).filter((column) => selectedCrmFieldKeys.includes(column.key));
const columns = isSalesTab ? salesColumns : isCrmTab ? crmColumns : channelColumns;
const headers = columns.map((column) => column.label);
const rows = isSalesTab
? buildExportRows(exportSalesItems, salesColumns)
: isCrmTab
? buildExportRows(exportCrmItems, crmColumns)
: 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 ? "销售人员拓展" : isCrmTab ? "CRM拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`;
downloadExcelFile(filename, buffer);
setExportFilterOpen(false);
} catch (error) {
setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
const renderSalesForm = (
form: CreateSalesExpansionPayload,
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
fieldErrors?: Partial<Record<SalesCreateField, string>>,
isEdit = false,
) => (
<div className="crm-form-grid">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.employeeNo))} />
{fieldErrors?.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
{!fieldErrors?.employeeNo && !isEdit && salesDuplicateMessage ? <p className="text-xs text-rose-500">{salesDuplicateMessage}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / <RequiredMark /></span>
<AdaptiveSelect
value={form.officeName || ""}
placeholder="请选择"
sheetTitle="代表处 / 办事处"
searchable
searchPlaceholder="搜索代表处 / 办事处"
options={[
{ value: "", label: "请选择" },
...officeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(
fieldErrors?.officeName ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("officeName", value || undefined)}
/>
{fieldErrors?.officeName ? <p className="text-xs text-rose-500">{fieldErrors.officeName}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.candidateName} onChange={(e) => onChange("candidateName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.candidateName))} />
{fieldErrors?.candidateName ? <p className="text-xs text-rose-500">{fieldErrors.candidateName}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.mobile} onChange={(e) => onChange("mobile", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.mobile))} />
{fieldErrors?.mobile ? <p className="text-xs text-rose-500">{fieldErrors.mobile}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<CoverageCascaderSelect
value={{ provinces: form.regionProvince ?? [], cities: form.regionCity ?? [], items: form.regionItems ?? [] }}
onChange={(next) => {
onChange("regionProvince", next.provinces);
onChange("regionCity", next.cities);
onChange("regionItems", next.items);
}}
provinceOptions={provinceOptions}
getCities={loadCityOptions}
isEdit={isEdit}
hasError={Boolean(fieldErrors?.regionProvince)}
/>
{fieldErrors?.regionProvince ? <p className="text-xs text-rose-500">{fieldErrors.regionProvince}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.industry || ""}
placeholder="请选择"
sheetTitle="所属行业"
options={[
{ value: "", label: "请选择" },
...industryOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(
fieldErrors?.industry ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("industry", value || undefined)}
/>
{fieldErrors?.industry ? <p className="text-xs text-rose-500">{fieldErrors.industry}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.title} onChange={(e) => onChange("title", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.title))} />
{fieldErrors?.title ? <p className="text-xs text-rose-500">{fieldErrors.title}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.intentLevel}
sheetTitle="合作意向"
options={[
{ value: "high", label: "高" },
{ value: "medium", label: "中" },
{ value: "low", label: "低" },
]}
className={cn(
fieldErrors?.intentLevel ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("intentLevel", value)}
/>
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.employmentStatus}
sheetTitle="销售是否在职"
options={[
{ value: "active", label: "是" },
{ value: "left", label: "否" },
]}
className={cn(
fieldErrors?.employmentStatus ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("employmentStatus", value)}
/>
{fieldErrors?.employmentStatus ? <p className="text-xs text-rose-500">{fieldErrors.employmentStatus}</p> : null}
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input type="checkbox" checked={Boolean(form.hasDesktopExp)} onChange={(e) => onChange("hasDesktopExp", e.target.checked)} />
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400">
</div>
</label>
</div>
);
const renderChannelForm = (
form: CreateChannelExpansionPayload,
onChange: <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => void,
isEdit = false,
fieldErrors?: Partial<Record<ChannelField, string>>,
invalidContactRows: number[] = [],
) => {
return (
<div className="crm-form-grid">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
value={form.channelCode || (isEdit ? "" : nextChannelCode || "系统自动生成")}
readOnly
className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400"
/>
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.channelName} onChange={(e) => onChange("channelName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.channelName))} />
{fieldErrors?.channelName ? <p className="text-xs text-rose-500">{fieldErrors.channelName}</p> : null}
{!fieldErrors?.channelName && !isEdit && channelDuplicateMessage ? <p className="text-xs text-rose-500">{channelDuplicateMessage}</p> : null}
</label>
<ChannelAddressField
value={{ province: form.province, city: form.city, officeAddress: form.officeAddress }}
onChange={(address) => {
onChange("province", address.province);
onChange("city", address.city);
onChange("officeAddress", address.officeAddress);
}}
provinceOptions={provinceOptions}
isEdit={isEdit}
errors={fieldErrors}
loadCityOptions={loadCityOptions}
/>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<CoverageCascaderSelect
value={{ provinces: form.coverageProvince ?? [], cities: form.coverageCity ?? [], items: form.coverageItems ?? [] }}
onChange={(next) => {
onChange("coverageProvince", next.provinces);
onChange("coverageCity", next.cities);
onChange("coverageItems", next.items);
}}
provinceOptions={provinceOptions}
getCities={loadCityOptions}
isEdit={isEdit}
hasError={Boolean(fieldErrors?.coverageProvince || fieldErrors?.coverageCity)}
/>
{(fieldErrors?.coverageProvince || fieldErrors?.coverageCity) ? (
<p className="text-xs text-rose-500">{fieldErrors?.coverageProvince || fieldErrors?.coverageCity}</p>
) : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={form.channelIndustry || []}
placeholder="请选择"
sheetTitle="聚焦行业"
options={industryOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(
fieldErrors?.channelIndustry ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("channelIndustry", value)}
/>
{fieldErrors?.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.certificationLevel || ""}
placeholder="请选择"
sheetTitle="汇智内部认证级别"
options={[
{ value: "", label: "请选择" },
...certificationLevelOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(
fieldErrors?.certificationLevel ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("certificationLevel", value || undefined)}
/>
{fieldErrors?.certificationLevel ? <p className="text-xs text-rose-500">{fieldErrors.certificationLevel}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{CHANNEL_REVENUE_LABEL}<RequiredMark /></span>
<input
type="number"
min="0.01"
step="0.01"
placeholder="请输入万元"
value={form.annualRevenue ?? ""}
onChange={(e) => onChange("annualRevenue", e.target.value ? Number(e.target.value) : undefined)}
className={getFieldInputClass(Boolean(fieldErrors?.annualRevenue))}
/>
{fieldErrors?.annualRevenue ? <p className="text-xs text-rose-500">{fieldErrors.annualRevenue}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="number" value={form.staffSize ?? ""} onChange={(e) => onChange("staffSize", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors?.staffSize))} />
{fieldErrors?.staffSize ? <p className="text-xs text-rose-500">{fieldErrors.staffSize}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{CHANNEL_REGISTERED_CAPITAL_LABEL}<RequiredMark /></span>
<input
type="number"
min="0.01"
step="0.01"
placeholder="请输入万元"
value={form.registeredCapital ?? ""}
onChange={(e) => onChange("registeredCapital", e.target.value ? Number(e.target.value) : undefined)}
className={getFieldInputClass(Boolean(fieldErrors?.registeredCapital))}
/>
{fieldErrors?.registeredCapital ? <p className="text-xs text-rose-500">{fieldErrors.registeredCapital}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.contactEstablishedDate || ""} onChange={(e) => onChange("contactEstablishedDate", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.contactEstablishedDate))} />
{fieldErrors?.contactEstablishedDate ? <p className="text-xs text-rose-500">{fieldErrors.contactEstablishedDate}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.intentLevel || "medium"}
sheetTitle="合作意向"
options={[
{ value: "high", label: "高" },
{ value: "medium", label: "中" },
{ value: "low", label: "低" },
]}
className={cn(
fieldErrors?.intentLevel ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("intentLevel", value)}
/>
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={form.channelAttribute || []}
placeholder="请选择"
sheetTitle="渠道属性"
options={channelAttributeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(
fieldErrors?.channelAttribute ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => {
onChange("channelAttribute", value);
if (!channelOtherOptionValue || !value.includes(channelOtherOptionValue)) {
onChange("channelAttributeCustom", "");
}
}}
/>
{fieldErrors?.channelAttribute ? <p className="text-xs text-rose-500">{fieldErrors.channelAttribute}</p> : null}
</label>
{channelOtherOptionValue && (form.channelAttribute ?? []).includes(channelOtherOptionValue) ? (
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
value={form.channelAttributeCustom || ""}
onChange={(e) => onChange("channelAttributeCustom", e.target.value)}
placeholder="请输入具体渠道属性"
className={getFieldInputClass(Boolean(fieldErrors?.channelAttributeCustom))}
/>
{fieldErrors?.channelAttributeCustom ? <p className="text-xs text-rose-500">{fieldErrors.channelAttributeCustom}</p> : null}
</label>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={form.internalAttribute || []}
placeholder="请选择"
sheetTitle="新华三内部属性"
options={internalAttributeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(
fieldErrors?.internalAttribute ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
)}
onChange={(value) => onChange("internalAttribute", value)}
/>
{fieldErrors?.internalAttribute ? <p className="text-xs text-rose-500">{fieldErrors.internalAttribute}</p> : null}
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input type="checkbox" checked={Boolean(form.hasDesktopExp)} onChange={(e) => onChange("hasDesktopExp", e.target.checked)} />
</label>
<div className="crm-form-section sm:col-span-2">
<div className="crm-form-section-header">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200"><RequiredMark /></span>
</div>
<ChannelContactRows
contacts={form.contacts ?? []}
invalidContactRows={invalidContactRows}
onContactChange={(index, key, value) => handleChannelContactChange(index, key, value, isEdit)}
wecomOptions={isOptions}
/>
{fieldErrors?.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
</div>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400">
</div>
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<textarea rows={4} value={form.remark} onChange={(e) => onChange("remark", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
</label>
</div>
);
};
const renderCrmForm = (
form: CreateCrmExpansionPayload,
onChange: <K extends keyof CreateCrmExpansionPayload>(key: K, value: CreateCrmExpansionPayload[K]) => void,
fieldErrors?: Partial<Record<keyof CreateCrmExpansionPayload, string>>,
isEdit = false,
invalidContactRows: number[] = [],
) => (
<div className="crm-form-grid">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.endUser} onChange={(e) => onChange("endUser", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.endUser))} />
{fieldErrors?.endUser ? <p className="text-xs text-rose-500">{fieldErrors.endUser}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.officeName || ""}
placeholder="请选择"
sheetTitle="代表处"
searchable
searchPlaceholder="搜索代表处"
options={[
{ value: "", label: "请选择" },
...officeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(fieldErrors?.officeName ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => onChange("officeName", value || "")}
/>
{fieldErrors?.officeName ? <p className="text-xs text-rose-500">{fieldErrors.officeName}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={form.industryAttr || []}
placeholder="请选择"
sheetTitle="行业属性"
options={industryOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(fieldErrors?.industryAttr ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => onChange("industryAttr", value)}
/>
{fieldErrors?.industryAttr ? <p className="text-xs text-rose-500">{fieldErrors.industryAttr}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={form.extensionType || []}
placeholder="请选择"
sheetTitle="类型"
options={extensionTypeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(fieldErrors?.extensionType ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => onChange("extensionType", value)}
/>
{fieldErrors?.extensionType ? <p className="text-xs text-rose-500">{fieldErrors.extensionType}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
type="number"
min={0}
value={form.softwarePoints ?? ""}
onChange={(e) => onChange("softwarePoints", e.target.value === "" ? undefined : Number(e.target.value))}
placeholder="请输入软件点数"
className={getFieldInputClass(Boolean(fieldErrors?.softwarePoints))}
/>
{fieldErrors?.softwarePoints ? <p className="text-xs text-rose-500">{fieldErrors.softwarePoints}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.purchaseDate} onChange={(e) => onChange("purchaseDate", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.purchaseDate))} />
{fieldErrors?.purchaseDate ? <p className="text-xs text-rose-500">{fieldErrors.purchaseDate}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.warrantyExpiry} onChange={(e) => onChange("warrantyExpiry", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.warrantyExpiry))} />
{fieldErrors?.warrantyExpiry ? <p className="text-xs text-rose-500">{fieldErrors.warrantyExpiry}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">线<RequiredMark /></span>
<AdaptiveSelect
value={form.onlineStatus || ""}
placeholder="请选择"
sheetTitle="在线情况"
options={[
{ value: "", label: "请选择" },
...onlineStatusOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(fieldErrors?.onlineStatus ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => onChange("onlineStatus", value || "")}
/>
{fieldErrors?.onlineStatus ? <p className="text-xs text-rose-500">{fieldErrors.onlineStatus}</p> : null}
</label>
<div className="crm-form-section sm:col-span-2">
<div className="crm-form-section-header">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200"><RequiredMark /></span>
<button type="button" onClick={() => addCrmContact(isEdit)} className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400">
</button>
</div>
<div className="crm-section-stack">
{(form.contacts ?? []).map((contact, index) => (
<div key={`${isEdit ? "edit" : "create"}-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-white p-3 sm:grid-cols-[1fr_1fr_1fr_auto] dark:border-slate-700 dark:bg-slate-900/50">
<input value={contact.name || ""} onChange={(e) => handleCrmContactChange(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")} />
<input value={contact.mobile || ""} onChange={(e) => handleCrmContactChange(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")} />
<input value={contact.title || ""} onChange={(e) => handleCrmContactChange(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")} />
<button type="button" onClick={() => removeCrmContact(index, isEdit)} className="crm-btn-danger rounded-lg px-3 py-2 text-sm font-medium">
</button>
</div>
))}
</div>
{fieldErrors?.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
</div>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchOrInputSelect
valueId={form.supplierId}
valueText={form.supplierName || ""}
options={supplierChannelSearchOptions}
placeholder="请选择进货商(来自渠道拓展)"
searchPlaceholder="搜索渠道名称,或直接输入文字"
emptyText="未找到匹配的渠道"
loading={loadingSupplierChannelOptions}
className={cn(fieldErrors?.supplierId ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(id, text) => {
onChange("supplierId", id || 0);
onChange("supplierName", text || "");
}}
onQueryChange={setSupplierChannelQuery}
/>
{fieldErrors?.supplierId ? <p className="text-xs text-rose-500">{fieldErrors.supplierId}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchOrInputSelect
valueId={form.h3cContactId}
valueText={form.h3cContactName || ""}
options={supplierSalesSearchOptions}
placeholder="请选择新华三对接人(来自销售拓展)"
searchPlaceholder="搜索姓名或工号,或直接输入文字"
emptyText="未找到匹配的销售拓展人员"
loading={loadingSupplierSalesOptions}
className={cn(fieldErrors?.h3cContactId ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(id, text) => {
onChange("h3cContactId", id || 0);
onChange("h3cContactName", text || "");
}}
onQueryChange={setSupplierSalesQuery}
/>
{fieldErrors?.h3cContactId ? <p className="text-xs text-rose-500">{fieldErrors.h3cContactId}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.hasExpansionOpportunity || ""}
placeholder="请选择"
sheetTitle="是否有扩容机会"
options={[
{ value: "", label: "请选择" },
...isOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(fieldErrors?.hasExpansionOpportunity ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => onChange("hasExpansionOpportunity", value || "")}
/>
{fieldErrors?.hasExpansionOpportunity ? <p className="text-xs text-rose-500">{fieldErrors.hasExpansionOpportunity}</p> : null}
</label>
{form.hasExpansionOpportunity === "1" ? (
<>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.expansionTime} onChange={(e) => onChange("expansionTime", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.expansionTime))} />
{fieldErrors?.expansionTime ? <p className="text-xs text-rose-500">{fieldErrors.expansionTime}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.expansionScale || ""} onChange={(e) => onChange("expansionScale", e.target.value)} placeholder="请输入扩容规模" className={getFieldInputClass(Boolean(fieldErrors?.expansionScale))} />
{fieldErrors?.expansionScale ? <p className="text-xs text-rose-500">{fieldErrors.expansionScale}</p> : null}
</label>
</>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.hasMaintenanceOpportunity || ""}
placeholder="请选择"
sheetTitle="是否有维保项目机会"
options={[
{ value: "", label: "请选择" },
...isOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(fieldErrors?.hasMaintenanceOpportunity ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => onChange("hasMaintenanceOpportunity", value || "")}
/>
{fieldErrors?.hasMaintenanceOpportunity ? <p className="text-xs text-rose-500">{fieldErrors.hasMaintenanceOpportunity}</p> : null}
</label>
</div>
);
return (
<div className="crm-page-stack">
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setExportError("");
setExportFilterOpen(true);
}}
disabled={exporting}
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
<Download className="crm-icon-md" />
<span className="hidden sm:inline">{exporting ? "导出中..." : "导出"}</span>
</button>
{canCreateExpansion ? (
<button
onClick={handleOpenCreate}
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
<Plus className="crm-icon-md" />
<span className="hidden sm:inline"></span>
</button>
) : null}
</div>
</header>
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
<button
onClick={() => handleTabChange("sales")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
activeTab === "sales" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
</button>
<button
onClick={() => handleTabChange("channel")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
activeTab === "channel" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
</button>
<button
onClick={() => handleTabChange("crm")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
activeTab === "crm" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
CRM
</button>
</div>
<div className="group relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 transition-colors group-focus-within:text-violet-500" />
<input
type="text"
placeholder="搜索工号、姓名、渠道名称、行业..."
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
setExportError("");
}}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pl-10 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white"
/>
</div>
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
<div className="crm-list-stack">
{activeTab === "sales" ? (
salesData.length > 0 ? (
salesData.slice(0, visibleItemCount).map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => handleSelectItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{item.active ? "在职" : "离职"}
</span>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span className={item.intent === "高" ? "font-medium text-rose-600 dark:text-rose-400" : ""}>{item.intent || "无"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
{formatRelatedProjectAmount(item.relatedProjects)}
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
</div>
</div>
</motion.div>
);
})
) : renderEmpty()
) : activeTab === "crm" ? (
crmData.length > 0 ? (
crmData.slice(0, visibleItemCount).map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => handleSelectItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.endUser || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{getDictLabelByValue(item.officeName, officeOptions) || "无"} · {item.industryAttr || "无"}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span>{item.extensionType || "无"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">线:</span>
<span>{item.onlineStatus || "无"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span>{item.softwarePoints == null ? "无" : String(item.softwarePoints)}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span>{item.expansionTimeText || "无"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span>{item.expansionScale || "无"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span>{item.hasMaintenanceOpportunity === "1" ? "是" : item.hasMaintenanceOpportunity === "0" ? "否" : "无"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
</div>
</div>
</motion.div>
);
})
) : renderEmpty()
) : (
channelData.length > 0 ? (
channelData.slice(0, visibleItemCount).map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => handleSelectItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
{item.intent ? `${item.intent}意向` : "未评估"}
</span>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
{item.establishedDate || "无"}
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
{formatRelatedProjectAmount(item.relatedProjects)}
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
</div>
</div>
</motion.div>
);
})
) : renderEmpty())}
{loadMoreError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 py-3 text-center text-sm font-medium text-rose-600 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-300">
{loadMoreError}
</div>
) : null}
{hasMoreItems ? (
<div
ref={loadMoreRef}
role="button"
tabIndex={0}
onClick={() => void loadMoreItems()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void loadMoreItems();
}
}}
className="cursor-pointer rounded-lg border border-slate-200 bg-white py-3 text-center text-sm font-medium text-violet-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-violet-300"
>
{loadingMore ? "加载中..." : "展示更多"}
</div>
) : null}
</div>
<AnimatePresence>
{exportFilterOpen && (
<ExpansionExportFilterModal
activeTab={activeTab}
initialFilters={applyDefaultRelatedProjectStageFilters(exportFilters, relatedProjectStageOptions)}
exporting={exporting}
exportError={exportError}
officeOptions={officeOptions}
industryOptions={industryOptions}
provinceOptions={provinceOptions}
certificationLevelOptions={certificationLevelOptions}
channelAttributeOptions={channelAttributeOptions}
relatedProjectStageOptions={relatedProjectStageOptions}
isOptions={isOptions}
onClose={() => setExportFilterOpen(false)}
onConfirm={(filters) => void handleExport(filters)}
/>
)}
{createOpen && (
<ModalShell
title={`新增${activeTab === "sales" ? "销售人员拓展" : activeTab === "crm" ? "CRM拓展" : "渠道拓展"}`}
subtitle="支持电脑和手机填写,提交后自动刷新列表。"
onClose={resetCreateState}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetCreateState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void handleCreateSubmit()} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : "确认新增"}</button>
</div>
)}
>
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors, false) : activeTab === "crm" ? renderCrmForm(crmForm, handleCrmChange, crmFieldErrors, false, invalidCreateCrmContactRows) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
{createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
</ModalShell>
)}
</AnimatePresence>
<AnimatePresence>
{editOpen && selectedItem && (
<ModalShell
title={`编辑${selectedItem.type === "sales" ? "销售人员拓展" : selectedItem.type === "crm" ? "CRM拓展" : "渠道拓展"}`}
subtitle="修改后会实时更新本人名下的拓展资料。"
onClose={resetEditState}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetEditState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void handleEditSubmit()} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "保存中..." : "保存修改"}</button>
</div>
)}
>
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors, true) : selectedItem.type === "crm" ? renderCrmForm(editCrmForm, handleEditCrmChange, editCrmFieldErrors, true, invalidEditCrmContactRows) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
</ModalShell>
)}
</AnimatePresence>
<AnimatePresence>
{moveOpen && selectedItem && (
<ModalShell
title={selectedItem.type === "channel" ? "移至CRM拓展" : "移至渠道拓展"}
subtitle={selectedItem.type === "channel" ? "将整条记录及关联跟进/打卡/日报迁移到CRM拓展请先补填CRM必填字段。注意该渠道关联的商机将在迁移后断开拓展关联其它以该渠道为进货商的CRM记录也将失去进货商。" : "将整条记录及关联跟进/打卡/日报迁移到渠道拓展,请先补填渠道必填字段。"}
onClose={resetMoveState}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetMoveState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void handleMoveSubmit()} disabled={moveSubmitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{moveSubmitting ? "迁移中..." : "确认迁移"}</button>
</div>
)}
>
{selectedItem.type === "channel" ? (
<div className="crm-form-grid">
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input value={selectedItem.name ?? ""} readOnly className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400" />
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={moveChannelForm.officeName || ""}
placeholder="请选择"
sheetTitle="代表处"
searchable
searchPlaceholder="搜索代表处"
options={[
{ value: "", label: "请选择" },
...officeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(moveChannelFieldErrors.officeName ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveChannelForm((current) => ({ ...current, officeName: value || "" }))}
/>
{moveChannelFieldErrors.officeName ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.officeName}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={moveChannelForm.extensionType || []}
placeholder="请选择"
sheetTitle="类型"
options={extensionTypeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(moveChannelFieldErrors.extensionType ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveChannelForm((current) => ({ ...current, extensionType: value }))}
/>
{moveChannelFieldErrors.extensionType ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.extensionType}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={moveChannelForm.purchaseDate} onChange={(e) => setMoveChannelForm((current) => ({ ...current, purchaseDate: e.target.value, warrantyExpiry: addYearsToDate(e.target.value, 3) }))} className={getFieldInputClass(Boolean(moveChannelFieldErrors.purchaseDate))} />
{moveChannelFieldErrors.purchaseDate ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.purchaseDate}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={moveChannelForm.warrantyExpiry} onChange={(e) => setMoveChannelForm((current) => ({ ...current, warrantyExpiry: e.target.value }))} className={getFieldInputClass(Boolean(moveChannelFieldErrors.warrantyExpiry))} />
{moveChannelFieldErrors.warrantyExpiry ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.warrantyExpiry}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">线<RequiredMark /></span>
<AdaptiveSelect
value={moveChannelForm.onlineStatus || ""}
placeholder="请选择"
sheetTitle="在线情况"
options={[
{ value: "", label: "请选择" },
...onlineStatusOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(moveChannelFieldErrors.onlineStatus ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveChannelForm((current) => ({ ...current, onlineStatus: value || "" }))}
/>
{moveChannelFieldErrors.onlineStatus ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.onlineStatus}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
type="number"
min={0}
value={moveChannelForm.softwarePoints ?? ""}
onChange={(e) => setMoveChannelForm((current) => ({ ...current, softwarePoints: e.target.value === "" ? undefined : Number(e.target.value) }))}
placeholder="请输入软件点数"
className={getFieldInputClass(Boolean(moveChannelFieldErrors.softwarePoints))}
/>
{moveChannelFieldErrors.softwarePoints ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.softwarePoints}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchOrInputSelect
valueId={moveChannelForm.supplierId}
valueText={moveChannelForm.supplierName || ""}
options={supplierChannelSearchOptions.filter((item) => String(item.value) !== String(selectedItem.id))}
placeholder="请选择进货商(来自渠道拓展)"
searchPlaceholder="搜索渠道名称,或直接输入文字"
emptyText="未找到匹配的渠道"
loading={loadingSupplierChannelOptions}
className={cn(moveChannelFieldErrors.supplierId ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(id, text) => setMoveChannelForm((current) => ({ ...current, supplierId: id || 0, supplierName: text || "" }))}
onQueryChange={setSupplierChannelQuery}
/>
{moveChannelFieldErrors.supplierId ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.supplierId}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchOrInputSelect
valueId={moveChannelForm.h3cContactId}
valueText={moveChannelForm.h3cContactName || ""}
options={supplierSalesSearchOptions}
placeholder="请选择新华三对接人(来自销售拓展)"
searchPlaceholder="搜索姓名或工号,或直接输入文字"
emptyText="未找到匹配的销售拓展人员"
loading={loadingSupplierSalesOptions}
className={cn(moveChannelFieldErrors.h3cContactId ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(id, text) => setMoveChannelForm((current) => ({ ...current, h3cContactId: id || 0, h3cContactName: text || "" }))}
onQueryChange={setSupplierSalesQuery}
/>
{moveChannelFieldErrors.h3cContactId ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.h3cContactId}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={moveChannelForm.hasExpansionOpportunity || ""}
placeholder="请选择"
sheetTitle="是否有扩容机会"
options={[
{ value: "", label: "请选择" },
...isOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(moveChannelFieldErrors.hasExpansionOpportunity ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveChannelForm((current) => ({ ...current, hasExpansionOpportunity: value || "" }))}
/>
{moveChannelFieldErrors.hasExpansionOpportunity ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.hasExpansionOpportunity}</p> : null}
</label>
{moveChannelForm.hasExpansionOpportunity === "1" ? (
<>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={moveChannelForm.expansionTime || ""} onChange={(e) => setMoveChannelForm((current) => ({ ...current, expansionTime: e.target.value }))} className={getFieldInputClass(Boolean(moveChannelFieldErrors.expansionTime))} />
{moveChannelFieldErrors.expansionTime ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.expansionTime}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={moveChannelForm.expansionScale || ""} onChange={(e) => setMoveChannelForm((current) => ({ ...current, expansionScale: e.target.value }))} placeholder="请输入扩容规模" className={getFieldInputClass(Boolean(moveChannelFieldErrors.expansionScale))} />
{moveChannelFieldErrors.expansionScale ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.expansionScale}</p> : null}
</label>
</>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={moveChannelForm.hasMaintenanceOpportunity || ""}
placeholder="请选择"
sheetTitle="是否有维保项目机会"
options={[
{ value: "", label: "请选择" },
...isOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(moveChannelFieldErrors.hasMaintenanceOpportunity ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveChannelForm((current) => ({ ...current, hasMaintenanceOpportunity: value || "" }))}
/>
{moveChannelFieldErrors.hasMaintenanceOpportunity ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.hasMaintenanceOpportunity}</p> : null}
</label>
<div className="crm-form-section sm:col-span-2">
<div className="crm-form-section-header">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200"><RequiredMark /></span>
<button type="button" onClick={addMoveCrmContact} className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400">
</button>
</div>
<div className="crm-section-stack">
{(moveChannelForm.contacts ?? []).map((contact, index) => (
<div key={`move-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-white p-3 sm:grid-cols-[1fr_1fr_1fr_auto] dark:border-slate-700 dark:bg-slate-900/50">
<input value={contact.name || ""} onChange={(e) => handleMoveCrmContactChange(index, "name", e.target.value)} 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", invalidMoveCrmContactRows.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")} />
<input value={contact.mobile || ""} onChange={(e) => handleMoveCrmContactChange(index, "mobile", e.target.value)} 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", invalidMoveCrmContactRows.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")} />
<input value={contact.title || ""} onChange={(e) => handleMoveCrmContactChange(index, "title", e.target.value)} 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", invalidMoveCrmContactRows.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")} />
<button type="button" onClick={() => removeMoveCrmContact(index)} className="crm-btn-danger rounded-lg px-3 py-2 text-sm font-medium">
</button>
</div>
))}
</div>
{moveChannelFieldErrors.contacts ? <p className="text-xs text-rose-500">{moveChannelFieldErrors.contacts}</p> : null}
</div>
</div>
) : (
<div className="crm-form-grid">
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input value={selectedItem.endUser ?? ""} readOnly className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400" />
</label>
<ChannelAddressField
value={{
// 优先取用户在表单里实际已选的省份,否则从源 CRM 代表处反查 label 作初始展示
province:
moveCrmForm.province ||
officeOptions.find((option) => option.value === selectedItem.officeName)?.label ||
selectedItem.officeName ||
"",
city: moveCrmForm.city || "",
officeAddress: moveCrmForm.officeAddress || "",
}}
onChange={(address) =>
setMoveCrmForm((current) => ({
...current,
province: address.province,
city: address.city,
officeAddress: address.officeAddress,
}))
}
provinceOptions={provinceOptions}
isEdit={false}
errors={moveCrmFieldErrors}
loadCityOptions={loadCityOptions}
/>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<CoverageCascaderSelect
value={{ provinces: moveCrmForm.coverageProvince ?? [], cities: moveCrmForm.coverageCity ?? [], items: moveCrmForm.coverageItems ?? [] }}
onChange={(next) =>
setMoveCrmForm((current) => ({
...current,
coverageProvince: next.provinces,
coverageCity: next.cities,
coverageItems: next.items,
}))
}
provinceOptions={provinceOptions}
getCities={loadCityOptions}
isEdit={false}
hasError={Boolean(moveCrmFieldErrors.coverageProvince || moveCrmFieldErrors.coverageCity)}
/>
{(moveCrmFieldErrors.coverageProvince || moveCrmFieldErrors.coverageCity) ? (
<p className="text-xs text-rose-500">{moveCrmFieldErrors.coverageProvince || moveCrmFieldErrors.coverageCity}</p>
) : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={moveCrmForm.certificationLevel || ""}
placeholder="请选择"
sheetTitle="汇智内部认证级别"
options={[
{ value: "", label: "请选择" },
...certificationLevelOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(moveCrmFieldErrors.certificationLevel ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveCrmForm((current) => ({ ...current, certificationLevel: value || "" }))}
/>
{moveCrmFieldErrors.certificationLevel ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.certificationLevel}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{CHANNEL_REVENUE_LABEL}<RequiredMark /></span>
<input
type="number"
min="0.01"
step="0.01"
placeholder="请输入万元"
value={moveCrmForm.annualRevenue ?? ""}
onChange={(e) => setMoveCrmForm((current) => ({ ...current, annualRevenue: e.target.value ? Number(e.target.value) : 0 }))}
className={getFieldInputClass(Boolean(moveCrmFieldErrors.annualRevenue))}
/>
{moveCrmFieldErrors.annualRevenue ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.annualRevenue}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="number" min="1" step="1" value={moveCrmForm.staffSize ?? ""} onChange={(e) => setMoveCrmForm((current) => ({ ...current, staffSize: e.target.value ? Number(e.target.value) : 0 }))} className={getFieldInputClass(Boolean(moveCrmFieldErrors.staffSize))} />
{moveCrmFieldErrors.staffSize ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.staffSize}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{CHANNEL_REGISTERED_CAPITAL_LABEL}<RequiredMark /></span>
<input
type="number"
min="0.01"
step="0.01"
placeholder="请输入万元"
value={moveCrmForm.registeredCapital ?? ""}
onChange={(e) => setMoveCrmForm((current) => ({ ...current, registeredCapital: e.target.value ? Number(e.target.value) : 0 }))}
className={getFieldInputClass(Boolean(moveCrmFieldErrors.registeredCapital))}
/>
{moveCrmFieldErrors.registeredCapital ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.registeredCapital}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={moveCrmForm.channelAttribute || []}
placeholder="请选择"
sheetTitle="渠道属性"
options={channelAttributeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(moveCrmFieldErrors.channelAttribute ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => {
setMoveCrmForm((current) => ({ ...current, channelAttribute: value }));
if (!channelOtherOptionValue || !value.includes(channelOtherOptionValue)) {
setMoveCrmForm((current) => ({ ...current, channelAttributeCustom: "" }));
}
}}
/>
{moveCrmFieldErrors.channelAttribute ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.channelAttribute}</p> : null}
</label>
{channelOtherOptionValue && (moveCrmForm.channelAttribute ?? []).includes(channelOtherOptionValue) ? (
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
value={moveCrmForm.channelAttributeCustom || ""}
onChange={(e) => setMoveCrmForm((current) => ({ ...current, channelAttributeCustom: e.target.value }))}
placeholder="请输入具体渠道属性"
className={getFieldInputClass(false)}
/>
</label>
) : null}
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
multiple
value={moveCrmForm.internalAttribute || []}
placeholder="请选择"
sheetTitle="新华三内部属性"
options={internalAttributeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
}))}
className={cn(moveCrmFieldErrors.internalAttribute ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveCrmForm((current) => ({ ...current, internalAttribute: value }))}
/>
{moveCrmFieldErrors.internalAttribute ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.internalAttribute}</p> : null}
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={moveCrmForm.intentLevel || ""}
placeholder="请选择"
sheetTitle="合作意向"
options={[
{ value: "high", label: "高" },
{ value: "medium", label: "中" },
{ value: "low", label: "低" },
]}
className={cn(moveCrmFieldErrors.intentLevel ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "")}
onChange={(value) => setMoveCrmForm((current) => ({ ...current, intentLevel: value || "" }))}
/>
{moveCrmFieldErrors.intentLevel ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.intentLevel}</p> : null}
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input type="checkbox" checked={Boolean(moveCrmForm.hasDesktopExp)} onChange={(e) => setMoveCrmForm((current) => ({ ...current, hasDesktopExp: e.target.checked }))} />
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input type="date" value={moveCrmForm.expectedSignDate || ""} onChange={(e) => setMoveCrmForm((current) => ({ ...current, expectedSignDate: e.target.value }))} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
</label>
<div className="crm-form-section sm:col-span-2">
<div className="crm-form-section-header">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200"><RequiredMark /></span>
</div>
<ChannelContactRows
contacts={moveCrmForm.contacts ?? []}
invalidContactRows={invalidMoveChannelContactRows}
onContactChange={handleMoveChannelContactChange}
wecomOptions={isOptions}
/>
{moveCrmFieldErrors.contacts ? <p className="text-xs text-rose-500">{moveCrmFieldErrors.contacts}</p> : null}
</div>
</div>
)}
{moveError ? <div className="crm-alert crm-alert-error mt-4">{moveError}</div> : null}
</ModalShell>
)}
</AnimatePresence>
<AnimatePresence>
{selectedItem && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedItem(null)}
className={`fixed inset-0 z-40 bg-slate-900/20 backdrop-blur-sm transition-opacity dark:bg-slate-900/60 ${
hasForegroundModal ? "pointer-events-none opacity-30" : ""
}`}
/>
<motion.div
initial={{ x: "100%", y: 0 }}
animate={{ x: 0, y: 0 }}
exit={{ x: "100%", y: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className={`fixed inset-x-0 bottom-0 z-50 flex h-[88dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl transition-opacity dark:border-slate-800 dark:bg-slate-900 sm:inset-y-0 sm:right-0 sm:left-auto sm:h-full sm:max-w-2xl lg:max-w-5xl sm:rounded-none sm:rounded-l-3xl sm:border-l ${
hasForegroundModal ? "pointer-events-none opacity-20" : ""
}`}
>
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="flex items-center gap-3">
<div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" />
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{selectedItem.type === "sales" ? "销售拓展详情" : selectedItem.type === "crm" ? "CRM拓展详情" : "渠道拓展详情"}</h2>
</div>
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="crm-icon-lg" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5 sm:px-6">
<div className="crm-modal-stack">
<div>
{selectedItem.type === "sales" ? (
<p className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> {selectedItem.employeeNo || "无"}</p>
) : selectedItem.type === "crm" ? (
<p className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">CRM</p>
) : (
<p className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">{selectedItem.channelCode || "未编码"}</p>
)}
<h3 className="break-anywhere text-lg font-bold text-slate-900 dark:text-white sm:text-xl">{selectedItem.type === "crm" ? (selectedItem.endUser || "无") : (selectedItem.name || "无")}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
{selectedItem.type === "sales"
? `${selectedItem.officeName || "无"} · ${selectedItem.dept || "无"} · ${selectedItem.title || "无"}`
: selectedItem.type === "crm"
? `${getDictLabelByValue(selectedItem.officeName, officeOptions) || "无"} · ${selectedItem.industryAttr || "无"}`
: `${selectedItem.province || "无"} · ${selectedItem.city || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.certificationLevel || "无"}`}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{selectedItem.type === "sales" ? (
<span className={`crm-pill ${selectedItem.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{selectedItem.active ? "在职" : "离职"}
</span>
) : null}
</div>
</div>
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<FileText className="crm-icon-md text-violet-500" />
</h4>
<div className="crm-detail-grid text-sm sm:grid-cols-2">
{selectedItem.type === "sales" ? (
<>
<DetailItem label="工号" value={selectedItem.employeeNo || "无"} />
<DetailItem label="代表处 / 办事处" value={selectedItem.officeName || "无"} />
<DetailItem label="联系方式" value={selectedItem.phone || "无"} icon={<Phone className="h-3 w-3" />} />
{(() => {
const pairing = new Map<string, string[]>();
(selectedItem.regionItems ?? "")
.split("")
.map((segment) => segment.trim())
.filter(Boolean)
.forEach((segment) => {
const [province = "", city = ""] = segment.split("|");
if (!province) return;
if (!pairing.has(province)) pairing.set(province, []);
if (city) pairing.get(province)!.push(city);
});
const groups = Array.from(pairing.entries());
const formatted = groups
.map(([province, cities]) =>
cities.length > 0 ? `${province}:${cities.join("、")}` : `${province}:全省`,
)
.join("");
return <DetailItem label="所属区域" value={formatted || "无"} className="sm:col-span-2" />;
})()}
<DetailItem label="所属行业" value={selectedItem.industry || "无"} icon={<Building2 className="h-3 w-3" />} />
<DetailItem label="职务" value={selectedItem.title || "无"} />
<DetailItem label="合作意向" value={selectedItem.intent || "无"} />
<DetailItem label="销售以前是否做过云桌面项目" value={selectedItem.hasExp ? "是" : "否"} />
<DetailItem label="销售是否在职" value={selectedItem.active ? "是" : "否"} />
</>
) : selectedItem.type === "crm" ? (
<>
<DetailItem label="最终用户" value={selectedItem.endUser || "无"} />
<DetailItem label="代表处" value={getDictLabelByValue(selectedItem.officeName, officeOptions) || "无"} />
<DetailItem label="行业属性" value={selectedItem.industryAttr || "无"} />
<DetailItem label="类型" value={selectedItem.extensionType || "无"} />
<DetailItem label="软件点数" value={selectedItem.softwarePoints == null ? "无" : String(selectedItem.softwarePoints)} />
<DetailItem label="采购时间" value={selectedItem.purchaseDateText || "无"} />
<DetailItem label="过保时间" value={selectedItem.warrantyExpiryText || "无"} />
<DetailItem label="在线情况" value={selectedItem.onlineStatus || "无"} />
<DetailItem label="进货商" value={selectedItem.supplierName || "无"} />
<DetailItem label="新华三对接人" value={selectedItem.h3cContactName || "无"} />
<DetailItem label="是否有扩容机会" value={selectedItem.hasExpansionOpportunity === "1" ? "是" : selectedItem.hasExpansionOpportunity === "0" ? "否" : "无"} />
{selectedItem.hasExpansionOpportunity === "1" ? (
<>
<DetailItem label="扩容时间" value={selectedItem.expansionTimeText || "无"} />
<DetailItem label="扩容规模" value={selectedItem.expansionScale || "无"} />
</>
) : null}
<DetailItem label="是否有维保项目机会" value={selectedItem.hasMaintenanceOpportunity === "1" ? "是" : selectedItem.hasMaintenanceOpportunity === "0" ? "否" : "无"} />
</>
) : (
<>
<DetailItem label="编码" value={selectedItem.channelCode || "无"} className="sm:col-span-2" />
<DetailItem
label="办公地区"
value={`${selectedItem.province || "无"}${selectedItem.city ? ` / ${selectedItem.city}` : ""}`}
/>
<DetailItem label="详细地址" value={selectedItem.officeAddress || "无"} />
{(() => {
const pairing = new Map<string, string[]>();
(selectedItem.coverageItems ?? "")
.split("")
.map((segment) => segment.trim())
.filter(Boolean)
.forEach((segment) => {
const [province = "", city = ""] = segment.split("|");
if (!province) return;
if (!pairing.has(province)) pairing.set(province, []);
if (city) pairing.get(province)!.push(city);
});
const groups = Array.from(pairing.entries());
const formatted = groups
.map(([province, cities]) =>
cities.length > 0 ? `${province}:${cities.join("、")}` : `${province}:全省`,
)
.join("");
return <DetailItem label="覆盖地市" value={groups.length > 0 ? formatted : "无"} className="sm:col-span-2" />;
})()}
<DetailItem label="聚焦行业" value={selectedItem.channelIndustry || "无"} icon={<Building2 className="h-3 w-3" />} />
<DetailItem label="汇智内部认证级别" value={selectedItem.certificationLevel || "无"} />
<DetailItem label={CHANNEL_REVENUE_LABEL} value={selectedItem.revenue || "无"} />
<DetailItem label="人员规模" value={`${selectedItem.size ?? 0}`} />
<DetailItem label={CHANNEL_REGISTERED_CAPITAL_LABEL} value={selectedItem.registeredCapital ? `${selectedItem.registeredCapital}万元` : "无"} />
<DetailItem label="建立联系时间" value={selectedItem.establishedDate || "无"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="合作意向" value={selectedItem.intent || "无"} />
<DetailItem label="渠道属性" value={selectedItem.channelAttribute || "无"} />
<DetailItem label="新华三内部属性" value={selectedItem.internalAttribute || "无"} />
<DetailItem label="以前是否做过云桌面项目" value={selectedItem.hasDesktopExp ? "是" : "否"} />
</>
)}
{selectedItem.type === "channel" ? <DetailItem label="备注说明" value={selectedItem.notes || "无"} className="sm:col-span-2" /> : null}
</div>
</div>
{selectedItem.type === "sales" ? (
<div className="crm-section-stack">
<div className="flex rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-800/40">
<button
type="button"
onClick={() => setSalesDetailTab("projects")}
className={`flex-1 rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
salesDetailTab === "projects"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
<button
type="button"
onClick={() => setSalesDetailTab("followups")}
className={`flex-1 rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
salesDetailTab === "followups"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
</div>
{salesDetailTab === "projects" ? (
<div className="crm-section-stack">
{(selectedItem.relatedProjects?.length ?? 0) > 0 ? (
<div className="crm-list-stack">
{selectedItem.relatedProjects?.map((project) => (
<div
key={project.opportunityId}
className="crm-detail-grid text-sm sm:grid-cols-3"
>
<DetailItem label="项目编码" value={project.opportunityCode || "无"} />
<DetailItem label="项目名称" value={project.opportunityName || "未命名项目"} />
<DetailItem label="项目金额" value={`¥${new Intl.NumberFormat("zh-CN").format(Number(project.amount || 0))}`} />
</div>
))}
</div>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
) : (
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Clock className="crm-icon-md text-violet-500" />
</h4>
{renderFollowUpTimeline()}
</div>
)}
</div>
) : selectedItem.type === "crm" ? (
<div className="crm-section-stack">
<div className="flex rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-800/40">
<button
type="button"
onClick={() => setCrmDetailTab("contacts")}
className={`flex-1 rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
crmDetailTab === "contacts"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
<button
type="button"
onClick={() => setCrmDetailTab("followups")}
className={`flex-1 rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
crmDetailTab === "followups"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
访
</button>
</div>
{crmDetailTab === "contacts" ? (
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<User className="crm-icon-md text-violet-500" />
</h4>
{(selectedItem.contacts?.length ?? 0) > 0 ? (
<div className="space-y-3">
{selectedItem.contacts?.map((contact, index) => (
<div key={`${contact.name || "contact"}-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-100 bg-slate-50/50 p-4 text-sm dark:border-slate-800 dark:bg-slate-800/20 sm:grid-cols-3">
<div><p className="mb-1 text-slate-500 dark:text-slate-400"></p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{contact.name || "无"}</p></div>
<div><p className="mb-1 text-slate-500 dark:text-slate-400"></p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{contact.mobile || "无"}</p></div>
<div><p className="mb-1 text-slate-500 dark:text-slate-400"></p><p className="break-anywhere font-medium text-slate-900 dark:text-white">{contact.title || "无"}</p></div>
</div>
))}
</div>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
) : null}
{crmDetailTab === "followups" ? (
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Clock className="crm-icon-md text-violet-500" />
访
</h4>
{renderFollowUpTimeline()}
</div>
) : null}
</div>
) : (
<div className="crm-section-stack">
<div className="flex rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-800/40">
<button
type="button"
onClick={() => setChannelDetailTab("projects")}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
channelDetailTab === "projects"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
<button
type="button"
onClick={() => setChannelDetailTab("contacts")}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
channelDetailTab === "contacts"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
<button
type="button"
onClick={() => setChannelDetailTab("followups")}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
channelDetailTab === "followups"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-900 dark:text-violet-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
</button>
</div>
{channelDetailTab === "projects" ? (
(selectedItem.relatedProjects?.length ?? 0) > 0 ? (
<div className="crm-list-stack">
{selectedItem.relatedProjects?.map((project) => (
<div
key={project.opportunityId}
className="crm-detail-grid text-sm sm:grid-cols-3"
>
<DetailItem label="项目编码" value={project.opportunityCode || "无"} />
<DetailItem label="项目名称" value={project.opportunityName || "未命名项目"} />
<DetailItem label="项目金额" value={`¥${new Intl.NumberFormat("zh-CN").format(Number(project.amount || 0))}`} />
</div>
))}
</div>
) : (
<div className="crm-empty-panel">
</div>
)
) : null}
{channelDetailTab === "contacts" ? (
(selectedItem.contacts?.length ?? 0) > 0 ? (
<div className="overflow-x-auto rounded-xl border border-slate-100 bg-slate-50/50 dark:border-slate-800 dark:bg-slate-800/20">
<table className="w-full min-w-[52rem] border-separate border-spacing-0 text-sm">
<thead>
<tr className="text-left text-xs text-slate-500 dark:text-slate-400">
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
<th className="whitespace-nowrap px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{selectedItem.contacts?.map((contact, index) => (
<tr key={`${contact.name || "contact"}-${index}`} className="border-t border-slate-100 align-top first:border-t-0 dark:border-slate-800">
<td className="break-anywhere px-3 py-2 font-medium text-slate-900 dark:text-white">{contact.duty || "无"}</td>
<td className="break-anywhere px-3 py-2 font-medium text-slate-900 dark:text-white">{contact.name || "无"}</td>
<td className="break-anywhere px-3 py-2 text-slate-700 dark:text-slate-300">{contact.title || "无"}</td>
<td className="break-anywhere px-3 py-2 text-slate-700 dark:text-slate-300">{contact.mobile || "无"}</td>
<td className="break-anywhere px-3 py-2 text-slate-700 dark:text-slate-300">{contact.birthday || "无"}</td>
<td className="break-anywhere px-3 py-2 text-slate-700 dark:text-slate-300">{contact.wecomAdded ? getDictLabelByValue(contact.wecomAdded, isOptions) : "无"}</td>
<td className="break-anywhere px-3 py-2 text-slate-700 dark:text-slate-300">{contact.specialNote || "无"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="crm-empty-panel">
</div>
)
) : null}
{channelDetailTab === "followups" ? (
renderFollowUpTimeline()
) : null}
</div>
)}
</div>
</div>
<div className="w-full shrink-0 border-t border-slate-200/80 bg-slate-50/95 backdrop-blur dark:border-slate-800/80 dark:bg-slate-900/90">
<div className="px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:p-4">
{!canEditSelectedItem ? (
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500"></p>
) : null}
<div className={`grid gap-3 ${selectedItem && (selectedItem.type === "channel" || selectedItem.type === "crm") ? "grid-cols-2" : "grid-cols-1"}`}>
<button
type="button"
onClick={() => void handleOpenEdit()}
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
className="crm-btn crm-btn-secondary inline-flex h-11 w-full items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
</button>
{selectedItem && (selectedItem.type === "channel" || selectedItem.type === "crm") ? (
<button
type="button"
onClick={() => void handleOpenMove()}
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? (selectedItem.type === "channel" ? "移至CRM拓展" : "移至渠道拓展") : "仅本人可操作"}
className="crm-btn crm-btn-secondary inline-flex h-11 w-full items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? (selectedItem.type === "channel" ? "移至CRM拓展" : "移至渠道拓展") : "仅本人可操作"}
</button>
) : null}
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
function formatAmount(value: number) {
return `¥${new Intl.NumberFormat("zh-CN").format(value)}`;
}
function sumRelatedProjectAmount(projects?: Array<{ amount?: number }>) {
if (!projects || projects.length === 0) {
return undefined;
}
const totalAmount = projects.reduce((sum, project) => sum + Number(project.amount || 0), 0);
return Number.isFinite(totalAmount) ? totalAmount : undefined;
}
function formatRelatedProjectAmount(projects?: Array<{ amount?: number }>) {
const totalAmount = sumRelatedProjectAmount(projects);
if (totalAmount === undefined) {
return "无";
}
return formatAmount(totalAmount);
}
function getExpansionFollowUpSummary(record: {
type?: string;
date?: string;
content?: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
}) {
const content = record.content || "";
const parsedVisit = extractFollowUpField(content, "拜访时间");
const parsedEvaluation = extractFollowUpField(content, "沟通内容");
const parsedPlan = extractFollowUpField(content, "后续规划");
const fallbackVisitStartTime = record.type === "工作日报" && record.date
? record.date.slice(0, 10)
: undefined;
return {
visitStartTime: normalizeFollowUpDisplayValue(formatFollowUpDateValue(
pickFollowUpValue(record.visitStartTime, parsedVisit, fallbackVisitStartTime),
)),
evaluationContent: normalizeFollowUpDisplayValue(
pickFollowUpValue(record.evaluationContent, parsedEvaluation),
),
nextPlan: normalizeFollowUpDisplayValue(
pickFollowUpValue(record.nextPlan, parsedPlan),
),
};
}
function pickFollowUpValue(...values: Array<string | undefined>) {
return values.find((value) => {
const normalized = value?.trim();
return normalized && normalized !== "无";
});
}
function formatFollowUpDateValue(value?: string) {
const normalized = value?.trim();
if (!normalized) {
return undefined;
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : normalized;
}
function extractFollowUpField(content: string, label: string) {
const normalized = content.replace(/\r/g, "");
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = normalized.match(new RegExp(`${escapedLabel}([^\\n]+)`));
return match?.[1]?.trim();
}
function normalizeFollowUpDisplayValue(value?: string) {
const normalized = value?.trim();
return normalized ? normalized : "无";
}