5676 lines
270 KiB
TypeScript
5676 lines
270 KiB
TypeScript
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 : "无";
|
||
}
|