unis_crm/frontend/src/pages/Expansion.tsx

2171 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

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

import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
import {
checkChannelExpansionDuplicate,
checkSalesExpansionDuplicate,
createChannelExpansion,
createSalesExpansion,
decodeExpansionMultiValue,
getExpansionCityOptions,
getExpansionMeta,
getExpansionOverview,
getStoredCurrentUserId,
updateChannelExpansion,
updateSalesExpansion,
type ChannelExpansionContact,
type ChannelExpansionItem,
type CreateChannelExpansionPayload,
type CreateSalesExpansionPayload,
type ExpansionDictOption,
type ExpansionFollowUp,
type SalesExpansionItem,
} from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel";
type SalesCreateField =
| "employeeNo"
| "officeName"
| "candidateName"
| "mobile"
| "targetDept"
| "industry"
| "title"
| "intentLevel"
| "employmentStatus";
type ChannelField =
| "channelName"
| "province"
| "city"
| "officeAddress"
| "channelIndustry"
| "certificationLevel"
| "annualRevenue"
| "staffSize"
| "contactEstablishedDate"
| "intentLevel"
| "channelAttribute"
| "channelAttributeCustom"
| "internalAttribute"
| "contacts";
function createEmptyChannelContact(): ChannelExpansionContact {
return {
name: "",
mobile: "",
title: "",
};
}
const defaultSalesForm: CreateSalesExpansionPayload = {
employeeNo: "",
candidateName: "",
officeName: "",
mobile: "",
industry: "",
title: "",
intentLevel: "medium",
hasDesktopExp: false,
employmentStatus: "active",
};
const defaultChannelForm: CreateChannelExpansionPayload = {
channelCode: "",
channelName: "",
province: "",
city: "",
officeAddress: "",
channelIndustry: [],
certificationLevel: "",
contactEstablishedDate: "",
intentLevel: "medium",
hasDesktopExp: false,
channelAttribute: [],
channelAttributeCustom: "",
internalAttribute: [],
stage: "initial_contact",
remark: "",
contacts: [createEmptyChannelContact()],
};
function isOtherOption(option?: ExpansionDictOption) {
const candidate = `${option?.label ?? ""}${option?.value ?? ""}`.toLowerCase();
return candidate.includes("其他") || candidate.includes("其它") || candidate.includes("other");
}
function normalizeOptionalText(value?: string) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function dedupeExpansionItemsById<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;
}
function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") {
if (value === null || value === undefined) {
return "";
}
return value ? trueLabel : falseLabel;
}
function formatExportFollowUps(followUps?: ExpansionFollowUp[]) {
if (!followUps?.length) {
return "";
}
return followUps
.map((followUp) => {
const summary = getExpansionFollowUpSummary(followUp);
const lines = [
[normalizeExportText(followUp.date), normalizeExportText(followUp.type)].filter(Boolean).join(" "),
normalizeExportText(summary.visitStartTime) ? `拜访时间:${normalizeExportText(summary.visitStartTime)}` : "",
normalizeExportText(summary.evaluationContent) ? `沟通内容:${normalizeExportText(summary.evaluationContent)}` : "",
normalizeExportText(summary.nextPlan) ? `后续规划:${normalizeExportText(summary.nextPlan)}` : "",
].filter(Boolean);
return lines.join("\n");
})
.filter(Boolean)
.join("\n\n");
}
function formatExportFilenameTime(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
}
function downloadExcelFile(filename: string, content: BlobPart) {
const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(objectUrl);
}
function buildSalesExportHeaders(items: SalesExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
const headers = [
"工号",
"姓名",
"创建人",
"联系方式",
"代表处 / 办事处",
"所属部门",
"职务",
"所属行业",
"合作意向",
"销售是否在职",
"销售以前是否做过云桌面项目",
"跟进项目金额",
];
for (let index = 0; index < maxProjects; index += 1) {
headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`);
}
headers.push("跟进记录");
return headers;
}
function buildSalesExportData(items: SalesExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
return items.map((item) => {
const row = [
normalizeExportText(item.employeeNo),
normalizeExportText(item.name),
normalizeExportText(item.owner),
normalizeExportText(item.phone),
normalizeExportText(item.officeName),
normalizeExportText(item.dept),
normalizeExportText(item.title),
normalizeExportText(item.industry),
normalizeExportText(item.intent),
item.active === null || item.active === undefined ? "" : item.active ? "是" : "否",
formatExportBoolean(item.hasExp),
normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)),
];
for (let index = 0; index < maxProjects; index += 1) {
const project = item.relatedProjects?.[index];
row.push(
normalizeExportText(project?.opportunityCode),
normalizeExportText(project?.opportunityName),
project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)),
);
}
row.push(formatExportFollowUps(item.followUps));
return row;
});
}
function buildChannelExportHeaders(items: ChannelExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0));
const headers = [
"编码",
"渠道名称",
"创建人",
"省份",
"市",
"办公地址",
"认证级别",
"聚焦行业",
"渠道属性",
"新华三内部属性",
"合作意向",
"建立联系时间",
"营收规模",
"人员规模",
"以前是否做过云桌面项目",
"跟进项目金额",
];
for (let index = 0; index < maxProjects; index += 1) {
headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`);
}
for (let index = 0; index < maxContacts; index += 1) {
headers.push(`人员${index + 1}姓名`, `人员${index + 1}联系电话`, `人员${index + 1}职位`);
}
headers.push("备注说明");
headers.push("跟进记录");
return headers;
}
function buildChannelExportData(items: ChannelExpansionItem[]) {
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0));
return items.map((item) => {
const row = [
normalizeExportText(item.channelCode),
normalizeExportText(item.name),
normalizeExportText(item.owner),
normalizeExportText(item.province),
normalizeExportText(item.city),
normalizeExportText(item.officeAddress),
normalizeExportText(item.certificationLevel),
normalizeExportText(item.channelIndustry),
normalizeExportText(item.channelAttribute),
normalizeExportText(item.internalAttribute),
normalizeExportText(item.intent),
normalizeExportText(item.establishedDate),
normalizeExportText(item.revenue),
item.size ? `${item.size}` : "",
formatExportBoolean(item.hasDesktopExp),
normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)),
];
for (let index = 0; index < maxProjects; index += 1) {
const project = item.relatedProjects?.[index];
row.push(
normalizeExportText(project?.opportunityCode),
normalizeExportText(project?.opportunityName),
project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)),
);
}
for (let index = 0; index < maxContacts; index += 1) {
const contact = item.contacts?.[index];
row.push(
normalizeExportText(contact?.name),
normalizeExportText(contact?.mobile),
normalizeExportText(contact?.title),
);
}
row.push(normalizeExportText(item.notes));
row.push(formatExportFollowUps(item.followUps));
return row;
});
}
function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
const errors: Partial<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.targetDept?.trim()) {
errors.targetDept = "请填写所属部门";
}
if (!form.industry?.trim()) {
errors.industry = "请选择所属行业";
}
if (!form.title?.trim()) {
errors.title = "请填写职务";
}
if (!form.intentLevel?.trim()) {
errors.intentLevel = "请选择合作意向";
}
if (!form.employmentStatus?.trim()) {
errors.employmentStatus = "请选择销售是否在职";
}
return errors;
}
function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOptionValue?: string) {
const errors: Partial<Record<ChannelField, string>> = {};
const invalidContactRows: number[] = [];
if (!form.channelName?.trim()) {
errors.channelName = "请填写渠道名称";
}
if (!form.province?.trim()) {
errors.province = "请选择省份";
}
if (!form.city?.trim()) {
errors.city = "请选择市";
}
if (!form.officeAddress?.trim()) {
errors.officeAddress = "请填写办公地址";
}
if (!form.certificationLevel?.trim()) {
errors.certificationLevel = "请选择认证级别";
}
if ((form.channelIndustry?.length ?? 0) <= 0) {
errors.channelIndustry = "请选择聚焦行业";
}
if (!form.annualRevenue || form.annualRevenue <= 0) {
errors.annualRevenue = "请填写年营收";
}
if (!form.staffSize || form.staffSize <= 0) {
errors.staffSize = "请填写人员规模";
}
if (!form.contactEstablishedDate?.trim()) {
errors.contactEstablishedDate = "请选择建立联系时间";
}
if (!form.intentLevel?.trim()) {
errors.intentLevel = "请选择合作意向";
}
if ((form.channelAttribute?.length ?? 0) <= 0) {
errors.channelAttribute = "请选择渠道属性";
}
if (channelOtherOptionValue && form.channelAttribute?.includes(channelOtherOptionValue) && !form.channelAttributeCustom?.trim()) {
errors.channelAttributeCustom = "请选择“其它”后请补充具体渠道属性";
}
if ((form.internalAttribute?.length ?? 0) <= 0) {
errors.internalAttribute = "请选择新华三内部属性";
}
const contacts = form.contacts ?? [];
if (contacts.length <= 0) {
errors.contacts = "请至少填写一位渠道联系人";
invalidContactRows.push(0);
} else {
contacts.forEach((contact, index) => {
const hasName = Boolean(contact.name?.trim());
const hasMobile = Boolean(contact.mobile?.trim());
const hasTitle = Boolean(contact.title?.trim());
if (!hasName || !hasMobile || !hasTitle) {
invalidContactRows.push(index);
}
});
if (invalidContactRows.length > 0) {
errors.contacts = "请完整填写每位渠道联系人的姓名、联系电话和职位";
}
}
return { errors, invalidContactRows };
}
function normalizeSalesPayload(payload: CreateSalesExpansionPayload): CreateSalesExpansionPayload {
return {
employeeNo: payload.employeeNo.trim(),
candidateName: payload.candidateName.trim(),
officeName: normalizeOptionalText(payload.officeName),
mobile: normalizeOptionalText(payload.mobile),
email: normalizeOptionalText(payload.email),
targetDept: normalizeOptionalText(payload.targetDept),
industry: normalizeOptionalText(payload.industry),
title: normalizeOptionalText(payload.title),
intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium",
stage: normalizeOptionalText(payload.stage) ?? "initial_contact",
hasDesktopExp: Boolean(payload.hasDesktopExp),
inProgress: payload.inProgress ?? true,
employmentStatus: normalizeOptionalText(payload.employmentStatus) ?? "active",
expectedJoinDate: normalizeOptionalText(payload.expectedJoinDate),
remark: normalizeOptionalText(payload.remark),
};
}
function normalizeChannelPayload(payload: CreateChannelExpansionPayload): CreateChannelExpansionPayload {
return {
channelCode: normalizeOptionalText(payload.channelCode),
officeAddress: normalizeOptionalText(payload.officeAddress),
channelIndustry: Array.from(new Set((payload.channelIndustry ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
channelName: payload.channelName.trim(),
province: normalizeOptionalText(payload.province),
city: normalizeOptionalText(payload.city),
certificationLevel: normalizeOptionalText(payload.certificationLevel),
annualRevenue: payload.annualRevenue || undefined,
staffSize: payload.staffSize || undefined,
contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate),
intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium",
hasDesktopExp: Boolean(payload.hasDesktopExp),
channelAttribute: Array.from(new Set((payload.channelAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
channelAttributeCustom: normalizeOptionalText(payload.channelAttributeCustom),
internalAttribute: Array.from(new Set((payload.internalAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
stage: normalizeOptionalText(payload.stage) ?? "initial_contact",
remark: normalizeOptionalText(payload.remark),
contacts: (payload.contacts ?? [])
.map((contact) => ({
name: normalizeOptionalText(contact.name),
mobile: normalizeOptionalText(contact.mobile),
title: normalizeOptionalText(contact.title),
}))
.filter((contact) => contact.name || contact.mobile || contact.title),
};
}
function normalizeOptionValue(rawValue: string | undefined, options: ExpansionDictOption[]) {
const trimmed = rawValue?.trim();
if (!trimmed) {
return "";
}
const matched = options.find((option) => option.value === trimmed || option.label === trimmed);
return matched?.value ?? trimmed;
}
function normalizeMultiOptionValues(rawValue: string | undefined, options: ExpansionDictOption[]) {
const { values } = decodeExpansionMultiValue(rawValue);
return Array.from(new Set(values.map((value) => normalizeOptionValue(value, options)).filter(Boolean)));
}
function ModalShell({
title,
subtitle,
onClose,
children,
footer,
}: {
title: string;
subtitle: string;
onClose: () => void;
children: ReactNode;
footer: ReactNode;
}) {
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
return (
<>
<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>;
}
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 [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 [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [internalAttributeOptions, setInternalAttributeOptions] = 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 [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false);
const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false);
const [createError, setCreateError] = useState("");
const [editError, setEditError] = useState("");
const [exportError, setExportError] = useState("");
const [salesDuplicateMessage, setSalesDuplicateMessage] = useState("");
const [channelDuplicateMessage, setChannelDuplicateMessage] = useState("");
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<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 canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const [salesForm, setSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const [editSalesForm, setEditSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const hasForegroundModal = createOpen || editOpen;
const loadMeta = useCallback(async () => {
const data = await getExpansionMeta();
setOfficeOptions(data.officeOptions ?? []);
setIndustryOptions(data.industryOptions ?? []);
setProvinceOptions(data.provinceOptions ?? []);
setCertificationLevelOptions(data.certificationLevelOptions ?? []);
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
setNextChannelCode(data.nextChannelCode ?? "");
return data;
}, []);
const loadCityOptions = useCallback(async (provinceName?: string, isEdit = false) => {
const setter = isEdit ? setEditCityOptions : setCreateCityOptions;
const normalizedProvinceName = provinceName?.trim();
if (!normalizedProvinceName) {
setter([]);
return [];
}
try {
const options = await getExpansionCityOptions(normalizedProvinceName);
setter(options ?? []);
return options ?? [];
} catch {
setter([]);
return [];
}
}, []);
useEffect(() => {
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
if (requestedTab === "sales" || requestedTab === "channel") {
setActiveTab(requestedTab);
}
}, [location.state]);
useEffect(() => {
let cancelled = false;
async function loadMetaOptions() {
try {
const data = await loadMeta();
if (!cancelled) {
setOfficeOptions(data.officeOptions ?? []);
setIndustryOptions(data.industryOptions ?? []);
setProvinceOptions(data.provinceOptions ?? []);
setCertificationLevelOptions(data.certificationLevelOptions ?? []);
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
setNextChannelCode(data.nextChannelCode ?? "");
}
} catch {
if (!cancelled) {
setOfficeOptions([]);
setIndustryOptions([]);
setProvinceOptions([]);
setCertificationLevelOptions([]);
setChannelAttributeOptions([]);
setInternalAttributeOptions([]);
setNextChannelCode("");
}
}
}
void loadMetaOptions();
return () => {
cancelled = true;
};
}, [loadMeta]);
useEffect(() => {
let cancelled = false;
async function loadExpansionData() {
try {
const data = await getExpansionOverview(keyword);
if (cancelled) {
return;
}
setSalesData(dedupeExpansionItemsById(data.salesItems ?? []));
setChannelData(dedupeExpansionItemsById(data.channelItems ?? []));
setSelectedItem(null);
} catch {
if (!cancelled) {
setSalesData([]);
setChannelData([]);
setSelectedItem(null);
}
}
}
void loadExpansionData();
return () => {
cancelled = true;
};
}, [keyword, refreshTick]);
const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? [];
useEffect(() => {
if (selectedItem?.type === "sales") {
setSalesDetailTab("projects");
} else if (selectedItem?.type === "channel") {
setChannelDetailTab("projects");
}
}, [selectedItem]);
useEffect(() => {
if (!createOpen || activeTab !== "sales") {
setSalesDuplicateChecking(false);
return;
}
const normalizedEmployeeNo = salesForm.employeeNo.trim();
if (!normalizedEmployeeNo) {
setSalesDuplicateChecking(false);
setSalesDuplicateMessage("");
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setSalesDuplicateChecking(true);
try {
const result = await checkSalesExpansionDuplicate(normalizedEmployeeNo);
if (!cancelled) {
setSalesDuplicateMessage(result.duplicated ? result.message || "工号重复,请确认该人员是否已存在!" : "");
}
} catch {
if (!cancelled) {
setSalesDuplicateMessage("");
}
} finally {
if (!cancelled) {
setSalesDuplicateChecking(false);
}
}
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeTab, createOpen, salesForm.employeeNo]);
useEffect(() => {
if (!createOpen || activeTab !== "channel") {
setChannelDuplicateChecking(false);
return;
}
const normalizedChannelName = channelForm.channelName.trim();
if (!normalizedChannelName) {
setChannelDuplicateChecking(false);
setChannelDuplicateMessage("");
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setChannelDuplicateChecking(true);
try {
const result = await checkChannelExpansionDuplicate(normalizedChannelName);
if (!cancelled) {
setChannelDuplicateMessage(result.duplicated ? result.message || "渠道重复,请确认该渠道是否已存在!" : "");
}
} catch {
if (!cancelled) {
setChannelDuplicateMessage("");
}
} finally {
if (!cancelled) {
setChannelDuplicateChecking(false);
}
}
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [activeTab, channelForm.channelName, createOpen]);
const handleSalesChange = <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 handleChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string, isEdit = false) => {
const setter = isEdit ? setEditChannelForm : setChannelForm;
setter((current) => {
const nextContacts = [...(current.contacts ?? [])];
const target = { ...(nextContacts[index] ?? createEmptyChannelContact()), [key]: value };
nextContacts[index] = target;
return { ...current, contacts: nextContacts };
});
if (isEdit) {
setChannelEditFieldErrors((current) => {
if (!current.contacts) {
return current;
}
const next = { ...current };
delete next.contacts;
return next;
});
setInvalidEditChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
return;
}
setChannelCreateFieldErrors((current) => {
if (!current.contacts) {
return current;
}
const next = { ...current };
delete next.contacts;
return next;
});
setInvalidCreateChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
};
const addChannelContact = (isEdit = false) => {
const setter = isEdit ? setEditChannelForm : setChannelForm;
setter((current) => ({
...current,
contacts: [...(current.contacts ?? []), createEmptyChannelContact()],
}));
};
const removeChannelContact = (index: number, isEdit = false) => {
const setter = isEdit ? setEditChannelForm : setChannelForm;
setter((current) => {
const currentContacts = current.contacts ?? [];
const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index);
return {
...current,
contacts: nextContacts.length > 0 ? nextContacts : [createEmptyChannelContact()],
};
});
};
const resetCreateState = () => {
setCreateOpen(false);
setCreateError("");
setSalesDuplicateChecking(false);
setChannelDuplicateChecking(false);
setSalesDuplicateMessage("");
setChannelDuplicateMessage("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
setSalesForm(defaultSalesForm);
setChannelForm(defaultChannelForm);
setCreateCityOptions([]);
};
const resetEditState = () => {
setEditOpen(false);
setEditError("");
setSalesEditFieldErrors({});
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
setEditSalesForm(defaultSalesForm);
setEditChannelForm(defaultChannelForm);
setEditCityOptions([]);
};
const handleOpenCreate = async () => {
setCreateError("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
try {
await loadMeta();
} catch {}
setCreateCityOptions([]);
setCreateOpen(true);
};
const handleOpenEdit = async () => {
if (!selectedItem) {
return;
}
if (!canEditSelectedItem) {
return;
}
setEditError("");
let latestIndustryOptions = industryOptions;
let latestCertificationLevelOptions = certificationLevelOptions;
try {
const meta = await loadMeta();
latestIndustryOptions = meta.industryOptions ?? [];
latestCertificationLevelOptions = meta.certificationLevelOptions ?? [];
} catch {}
if (selectedItem.type === "sales") {
setSalesEditFieldErrors({});
setEditSalesForm({
employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "",
candidateName: selectedItem.name ?? "",
officeName: selectedItem.officeCode ?? "",
mobile: selectedItem.phone === "无" ? "" : selectedItem.phone ?? "",
targetDept: selectedItem.dept === "无" ? "" : selectedItem.dept ?? selectedItem.targetDept ?? "",
industry: selectedItem.industryCode ?? "",
title: selectedItem.title === "无" ? "" : selectedItem.title ?? "",
intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasExp),
employmentStatus: selectedItem.active ? "active" : "left",
});
} else {
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
const normalizedProvinceName = selectedItem.province === "无" ? "" : selectedItem.province ?? "";
const normalizedCityName = selectedItem.city === "无" ? "" : selectedItem.city ?? "";
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
setEditChannelForm({
channelCode: selectedItem.channelCode ?? "",
channelName: selectedItem.name ?? "",
province: normalizedProvinceName,
city: normalizedCityName,
officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "",
channelIndustry: normalizeMultiOptionValues(
selectedItem.channelIndustryCode ?? (selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry),
latestIndustryOptions,
),
certificationLevel: normalizeOptionValue(
selectedItem.certificationLevel === "无" ? "" : selectedItem.certificationLevel,
latestCertificationLevelOptions,
) || "",
annualRevenue: selectedItem.annualRevenue ? Number(selectedItem.annualRevenue) : undefined,
staffSize: selectedItem.size ?? undefined,
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasDesktopExp),
channelAttribute: parsedChannelAttributes.values,
channelAttributeCustom: parsedChannelAttributes.customText,
internalAttribute: parsedInternalAttributes.values,
stage: selectedItem.stageCode ?? "initial_contact",
remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "",
contacts: (selectedItem.contacts?.length ?? 0) > 0
? selectedItem.contacts?.map((contact) => ({
name: contact.name === "无" ? "" : contact.name ?? "",
mobile: contact.mobile === "无" ? "" : contact.mobile ?? "",
title: contact.title === "无" ? "" : contact.title ?? "",
}))
: [createEmptyChannelContact()],
});
void loadCityOptions(normalizedProvinceName, true);
}
setEditOpen(true);
};
const handleCreateSubmit = async () => {
if (submitting) {
return;
}
setCreateError("");
if (activeTab === "sales") {
const validationErrors = validateSalesCreateForm(salesForm);
if (Object.keys(validationErrors).length > 0) {
setSalesCreateFieldErrors(validationErrors);
setCreateError("请先完整填写销售人员拓展必填字段");
return;
}
} else {
const { errors: validationErrors, invalidContactRows } = validateChannelForm(channelForm, channelOtherOptionValue);
if (Object.keys(validationErrors).length > 0) {
setChannelCreateFieldErrors(validationErrors);
setInvalidCreateChannelContactRows(invalidContactRows);
setCreateError("请先完整填写渠道拓展必填字段");
return;
}
}
if (activeTab === "sales") {
const duplicateResult = await checkSalesExpansionDuplicate(salesForm.employeeNo.trim());
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!";
setSalesDuplicateMessage(duplicateMessage);
setSalesCreateFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage }));
setCreateError(duplicateMessage);
return;
}
} else {
const duplicateResult = await checkChannelExpansionDuplicate(channelForm.channelName.trim());
if (duplicateResult.duplicated) {
const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
setChannelDuplicateMessage(duplicateMessage);
setChannelCreateFieldErrors((current) => ({ ...current, channelName: duplicateMessage }));
setCreateError(duplicateMessage);
return;
}
}
setSubmitting(true);
try {
if (activeTab === "sales") {
await createSalesExpansion(normalizeSalesPayload(salesForm));
} else {
await createChannelExpansion(normalizeChannelPayload(channelForm));
}
resetCreateState();
setRefreshTick((current) => current + 1);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "新增失败");
} finally {
setSubmitting(false);
}
};
const handleEditSubmit = async () => {
if (!selectedItem || submitting) {
return;
}
if (!canEditSelectedItem) {
setEditError("仅可编辑本人创建的数据");
return;
}
setEditError("");
if (selectedItem.type === "sales") {
const validationErrors = validateSalesCreateForm(editSalesForm);
if (Object.keys(validationErrors).length > 0) {
setSalesEditFieldErrors(validationErrors);
setEditError("请先完整填写销售人员拓展必填字段");
return;
}
} else {
const { errors: validationErrors, invalidContactRows } = validateChannelForm(editChannelForm, channelOtherOptionValue);
if (Object.keys(validationErrors).length > 0) {
setChannelEditFieldErrors(validationErrors);
setInvalidEditChannelContactRows(invalidContactRows);
setEditError("请先完整填写渠道拓展必填字段");
return;
}
}
setSubmitting(true);
try {
if (selectedItem.type === "sales") {
await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm));
} else {
await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm));
}
resetEditState();
setSelectedItem(null);
setRefreshTick((current) => current + 1);
} catch (error) {
setEditError(error instanceof Error ? error.message : "编辑失败");
} finally {
setSubmitting(false);
}
};
const renderEmpty = () => (
<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">
<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 () => {
if (exporting) {
return;
}
const isSalesTab = activeTab === "sales";
const items = isSalesTab ? salesData : channelData;
if (items.length <= 0) {
setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`);
return;
}
setExporting(true);
setExportError("");
try {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展");
const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData);
const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData);
worksheet.addRow(headers);
rows.forEach((row) => {
worksheet.addRow(row);
});
worksheet.views = [{ state: "frozen", ySplit: 1 }];
const followUpColumnIndex = headers.indexOf("跟进记录") + 1;
worksheet.getRow(1).height = 24;
worksheet.getRow(1).font = { bold: true };
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
headers.forEach((header, index) => {
const column = worksheet.getColumn(index + 1);
if (header === "跟进记录") {
column.width = 42;
} else if (header.includes("办公地址") || header.includes("备注")) {
column.width = 24;
} else if (header.includes("项目") && header.includes("名称")) {
column.width = 24;
} else if (header.includes("渠道属性") || header.includes("内部属性") || header.includes("聚焦行业")) {
column.width = 18;
} else {
column.width = 16;
}
});
worksheet.eachRow((row, rowNumber) => {
row.eachCell((cell, columnNumber) => {
cell.border = {
top: { style: "thin", color: { argb: "FFE2E8F0" } },
left: { style: "thin", color: { argb: "FFE2E8F0" } },
bottom: { style: "thin", color: { argb: "FFE2E8F0" } },
right: { style: "thin", color: { argb: "FFE2E8F0" } },
};
cell.alignment = {
vertical: "top",
horizontal: rowNumber === 1 ? "center" : "left",
wrapText: headers[columnNumber - 1] === "跟进记录",
};
});
if (rowNumber > 1 && followUpColumnIndex > 0) {
const followUpText = normalizeExportText(row.getCell(followUpColumnIndex).value as string | null | undefined);
const lineCount = followUpText ? followUpText.split("\n").length : 1;
row.height = Math.max(22, lineCount * 16);
}
});
const buffer = await workbook.xlsx.writeBuffer();
const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`;
downloadExcelFile(filename, buffer);
} catch (error) {
setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
const renderSalesForm = (
form: CreateSalesExpansionPayload,
onChange: <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>
<input
value={form.targetDept || ""}
onChange={(e) => onChange("targetDept", e.target.value)}
placeholder="办事处/地市"
className={getFieldInputClass(Boolean(fieldErrors?.targetDept))}
/>
{fieldErrors?.targetDept ? <p className="text-xs text-rose-500">{fieldErrors.targetDept}</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[] = [],
) => {
const cityOptions = isEdit ? editCityOptions : createCityOptions;
const cityDisabled = !form.province?.trim();
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>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect
value={form.province || ""}
placeholder="请选择"
sheetTitle="省份"
searchable
searchPlaceholder="搜索省份"
options={[
{ value: "", label: "请选择" },
...provinceOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(
fieldErrors?.province ? "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) => {
const nextProvince = value || undefined;
onChange("province", nextProvince);
onChange("city", undefined);
void loadCityOptions(nextProvince, isEdit);
}}
/>
{fieldErrors?.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</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.city || ""}
placeholder={cityDisabled ? "请先选择省份" : "请选择"}
sheetTitle="市"
searchable
searchPlaceholder="搜索市"
disabled={cityDisabled}
options={[
{ value: "", label: "请选择" },
...cityOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
className={cn(
fieldErrors?.city ? "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("city", value || undefined)}
/>
{fieldErrors?.city ? <p className="text-xs text-rose-500">{fieldErrors.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={form.officeAddress || ""} onChange={(e) => onChange("officeAddress", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.officeAddress))} />
{fieldErrors?.officeAddress ? <p className="text-xs text-rose-500">{fieldErrors.officeAddress}</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"><RequiredMark /></span>
<input type="number" 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"><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>
<button type="button" onClick={() => addChannelContact(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) => handleChannelContactChange(index, "name", e.target.value, isEdit)} placeholder="人员姓名" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} />
<input value={contact.mobile || ""} onChange={(e) => handleChannelContactChange(index, "mobile", e.target.value, isEdit)} placeholder="联系电话" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} />
<input value={contact.title || ""} onChange={(e) => handleChannelContactChange(index, "title", e.target.value, isEdit)} placeholder="职位" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} />
<button type="button" onClick={() => removeChannelContact(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 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>
);
};
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={handleExport}
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>
<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>
</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>
</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.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={() => setSelectedItem(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()
) : channelData.length > 0 ? (
channelData.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={() => setSelectedItem(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()}
</div>
<AnimatePresence>
{createOpen && (
<ModalShell
title={`新增${activeTab === "sales" ? "销售人员拓展" : "渠道拓展"}`}
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) : 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" ? "销售人员拓展" : "渠道拓展"}`}
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) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</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 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-3xl 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" ? "销售拓展详情" : "渠道拓展详情"}</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="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>
) : (
<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.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.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" />} />
<DetailItem label="所属部门" value={selectedItem.dept || "无"} />
<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 ? "是" : "否"} />
</>
) : (
<>
<DetailItem label="编码" value={selectedItem.channelCode || "无"} />
<DetailItem label="省份" value={selectedItem.province || "无"} />
<DetailItem label="市" value={selectedItem.city || "无"} />
<DetailItem label="认证级别" value={selectedItem.certificationLevel || "无"} />
<DetailItem label="办公地址" value={selectedItem.officeAddress || "无"} className="sm:col-span-2" />
<DetailItem label="聚焦行业" value={selectedItem.channelIndustry || "无"} icon={<Building2 className="h-3 w-3" />} />
<DetailItem label="营收规模" value={selectedItem.revenue || "无"} />
<DetailItem label="人员规模" value={`${selectedItem.size ?? 0}`} />
<DetailItem label="建立联系时间" value={selectedItem.establishedDate || "无"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="合作意向" value={selectedItem.intent || "无"} />
<DetailItem label="以前是否做过云桌面项目" value={selectedItem.hasDesktopExp ? "是" : "否"} />
<DetailItem label="渠道属性" value={selectedItem.channelAttribute || "无"} />
<DetailItem label="新华三内部属性" value={selectedItem.internalAttribute || "无"} />
</>
)}
{selectedItem.type !== "sales" ? <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>
) : (
<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="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>
)
) : null}
{channelDetailTab === "followups" ? (
renderFollowUpTimeline()
) : null}
</div>
)}
</div>
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
{!canEditSelectedItem ? (
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500"></p>
) : null}
<div className="flex">
<button
type="button"
onClick={() => void handleOpenEdit()}
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
className="crm-btn-sm crm-btn-secondary flex-1 disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
</button>
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
function formatAmount(value: number) {
return `¥${new Intl.NumberFormat("zh-CN").format(value)}`;
}
function formatRelatedProjectAmount(projects?: Array<{ amount?: number }>) {
if (!projects || projects.length === 0) {
return "无";
}
const totalAmount = projects.reduce((sum, project) => sum + Number(project.amount || 0), 0);
return formatAmount(totalAmount);
}
function getExpansionFollowUpSummary(record: {
type?: string;
date?: string;
content?: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
}) {
const content = record.content || "";
const parsedVisit = extractFollowUpField(content, "拜访时间");
const parsedEvaluation = extractFollowUpField(content, "沟通内容");
const parsedPlan = extractFollowUpField(content, "后续规划");
const fallbackVisitStartTime = record.type === "工作日报" && record.date
? record.date.slice(0, 10)
: undefined;
return {
visitStartTime: normalizeFollowUpDisplayValue(formatFollowUpDateValue(
pickFollowUpValue(record.visitStartTime, parsedVisit, fallbackVisitStartTime),
)),
evaluationContent: normalizeFollowUpDisplayValue(
pickFollowUpValue(record.evaluationContent, parsedEvaluation),
),
nextPlan: normalizeFollowUpDisplayValue(
pickFollowUpValue(record.nextPlan, parsedPlan),
),
};
}
function pickFollowUpValue(...values: Array<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 : "无";
}