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