unis_crm/frontend/src/pages/Expansion.tsx

1683 lines
79 KiB
TypeScript
Raw Normal View History

2026-04-01 09:24:06 +00:00
import { useCallback, useEffect, useState, type ReactNode } from "react";
2026-03-26 09:29:55 +00:00
import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
2026-03-19 06:23:03 +00:00
import { motion, AnimatePresence } from "motion/react";
2026-03-27 04:22:00 +00:00
import { useLocation } from "react-router-dom";
2026-03-20 08:39:07 +00:00
import {
createChannelExpansion,
createSalesExpansion,
2026-03-27 09:05:41 +00:00
decodeExpansionMultiValue,
2026-04-01 09:24:06 +00:00
getExpansionCityOptions,
2026-03-20 08:39:07 +00:00
getExpansionMeta,
getExpansionOverview,
updateChannelExpansion,
updateSalesExpansion,
2026-03-26 09:29:55 +00:00
type ChannelExpansionContact,
2026-03-20 08:39:07 +00:00
type ChannelExpansionItem,
type CreateChannelExpansionPayload,
type CreateSalesExpansionPayload,
2026-03-26 09:29:55 +00:00
type ExpansionDictOption,
2026-03-20 08:39:07 +00:00
type ExpansionFollowUp,
type SalesExpansionItem,
} from "@/lib/auth";
2026-03-26 09:29:55 +00:00
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
2026-03-27 09:05:41 +00:00
import { cn } from "@/lib/utils";
2026-03-20 08:39:07 +00:00
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel";
2026-03-27 09:05:41 +00:00
type SalesCreateField =
| "employeeNo"
| "officeName"
| "candidateName"
| "mobile"
| "targetDept"
| "industry"
| "title"
| "intentLevel"
| "employmentStatus";
type ChannelField =
| "channelName"
| "province"
2026-04-01 09:24:06 +00:00
| "city"
2026-03-27 09:05:41 +00:00
| "officeAddress"
| "channelIndustry"
2026-04-01 09:24:06 +00:00
| "certificationLevel"
2026-03-27 09:05:41 +00:00
| "annualRevenue"
| "staffSize"
| "contactEstablishedDate"
| "intentLevel"
| "channelAttribute"
| "channelAttributeCustom"
| "internalAttribute"
| "contacts";
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
2026-03-26 09:29:55 +00:00
function createEmptyChannelContact(): ChannelExpansionContact {
return {
name: "",
mobile: "",
title: "",
};
}
2026-03-20 08:39:07 +00:00
const defaultSalesForm: CreateSalesExpansionPayload = {
2026-03-26 09:29:55 +00:00
employeeNo: "",
2026-03-20 08:39:07 +00:00
candidateName: "",
2026-03-26 09:29:55 +00:00
officeName: "",
2026-03-20 08:39:07 +00:00
mobile: "",
industry: "",
title: "",
intentLevel: "medium",
hasDesktopExp: false,
employmentStatus: "active",
};
const defaultChannelForm: CreateChannelExpansionPayload = {
2026-03-26 09:29:55 +00:00
channelCode: "",
2026-03-20 08:39:07 +00:00
channelName: "",
province: "",
2026-04-01 09:24:06 +00:00
city: "",
2026-03-26 09:29:55 +00:00
officeAddress: "",
2026-04-01 09:24:06 +00:00
channelIndustry: [],
certificationLevel: "",
2026-03-26 09:29:55 +00:00
contactEstablishedDate: "",
intentLevel: "medium",
hasDesktopExp: false,
2026-03-27 09:05:41 +00:00
channelAttribute: [],
channelAttributeCustom: "",
internalAttribute: [],
2026-03-20 08:39:07 +00:00
stage: "initial_contact",
remark: "",
2026-03-26 09:29:55 +00:00
contacts: [createEmptyChannelContact()],
2026-03-20 08:39:07 +00:00
};
2026-03-27 09:05:41 +00:00
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 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 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()) {
2026-04-01 09:24:06 +00:00
errors.province = "请选择省份";
}
if (!form.city?.trim()) {
errors.city = "请选择市";
2026-03-27 09:05:41 +00:00
}
if (!form.officeAddress?.trim()) {
errors.officeAddress = "请填写办公地址";
}
2026-04-01 09:24:06 +00:00
if (!form.certificationLevel?.trim()) {
errors.certificationLevel = "请选择认证级别";
}
if ((form.channelIndustry?.length ?? 0) <= 0) {
errors.channelIndustry = "请选择聚焦行业";
2026-03-27 09:05:41 +00:00
}
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),
2026-04-01 09:24:06 +00:00
channelIndustry: Array.from(new Set((payload.channelIndustry ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
2026-03-27 09:05:41 +00:00
channelName: payload.channelName.trim(),
province: normalizeOptionalText(payload.province),
2026-04-01 09:24:06 +00:00
city: normalizeOptionalText(payload.city),
certificationLevel: normalizeOptionalText(payload.certificationLevel),
2026-03-27 09:05:41 +00:00
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),
};
}
2026-04-01 09:24:06 +00:00
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)));
}
2026-03-20 08:39:07 +00:00
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;
2026-03-20 08:39:07 +00:00
return (
<>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0 }}
2026-03-20 08:39:07 +00:00
animate={{ opacity: 1 }}
exit={disableMobileMotion ? undefined : { opacity: 0 }}
2026-03-20 08:39:07 +00:00
onClick={onClose}
className={cn("fixed inset-0 z-[70] bg-slate-900/35 dark:bg-slate-950/70", !disableMobileMotion && "backdrop-blur-sm")}
2026-03-20 08:39:07 +00:00
/>
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
2026-03-20 08:39:07 +00:00
animate={{ opacity: 1, y: 0 }}
exit={disableMobileMotion ? undefined : { opacity: 0, y: 20 }}
transition={disableMobileMotion ? { duration: 0 } : undefined}
2026-03-26 09:29:55 +00:00
className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
2026-03-20 08:39:07 +00:00
>
2026-03-26 09:29:55 +00:00
<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">
2026-03-20 08:39:07 +00:00
<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>
2026-03-26 09:29:55 +00:00
<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>
2026-03-20 08:39:07 +00:00
</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>
2026-03-26 09:29:55 +00:00
<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>
2026-03-20 08:39:07 +00:00
</div>
</div>
</motion.div>
</>
);
}
2026-03-19 06:23:03 +00:00
2026-03-26 09:29:55 +00:00
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>
);
}
2026-03-27 09:05:41 +00:00
function RequiredMark() {
return <span className="ml-1 text-rose-500">*</span>;
}
2026-03-19 06:23:03 +00:00
export default function Expansion() {
2026-03-27 04:22:00 +00:00
const location = useLocation();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
2026-03-20 08:39:07 +00:00
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[]>([]);
2026-03-26 09:29:55 +00:00
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
const [industryOptions, setIndustryOptions] = useState<ExpansionDictOption[]>([]);
2026-04-01 09:24:06 +00:00
const [provinceOptions, setProvinceOptions] = useState<ExpansionDictOption[]>([]);
const [certificationLevelOptions, setCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
const [createCityOptions, setCreateCityOptions] = useState<ExpansionDictOption[]>([]);
const [editCityOptions, setEditCityOptions] = useState<ExpansionDictOption[]>([]);
2026-03-26 09:29:55 +00:00
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [nextChannelCode, setNextChannelCode] = useState("");
2026-03-27 09:05:41 +00:00
const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? "";
2026-03-20 08:39:07 +00:00
const [refreshTick, setRefreshTick] = useState(0);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [createError, setCreateError] = useState("");
const [editError, setEditError] = useState("");
2026-03-27 09:05:41 +00:00
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[]>([]);
2026-03-26 09:29:55 +00:00
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
2026-03-20 08:39:07 +00:00
const [salesForm, setSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const [editSalesForm, setEditSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
2026-03-26 09:29:55 +00:00
const hasForegroundModal = createOpen || editOpen;
2026-03-20 08:39:07 +00:00
2026-04-01 09:24:06 +00:00
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 [];
}
}, []);
2026-03-27 04:22:00 +00:00
useEffect(() => {
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
if (requestedTab === "sales" || requestedTab === "channel") {
setActiveTab(requestedTab);
}
}, [location.state]);
2026-03-20 08:39:07 +00:00
useEffect(() => {
let cancelled = false;
2026-04-01 09:24:06 +00:00
async function loadMetaOptions() {
2026-03-20 08:39:07 +00:00
try {
2026-04-01 09:24:06 +00:00
const data = await loadMeta();
2026-03-20 08:39:07 +00:00
if (!cancelled) {
2026-03-26 09:29:55 +00:00
setOfficeOptions(data.officeOptions ?? []);
setIndustryOptions(data.industryOptions ?? []);
2026-04-01 09:24:06 +00:00
setProvinceOptions(data.provinceOptions ?? []);
setCertificationLevelOptions(data.certificationLevelOptions ?? []);
2026-03-26 09:29:55 +00:00
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
setNextChannelCode(data.nextChannelCode ?? "");
2026-03-20 08:39:07 +00:00
}
} catch {
if (!cancelled) {
2026-03-26 09:29:55 +00:00
setOfficeOptions([]);
setIndustryOptions([]);
2026-04-01 09:24:06 +00:00
setProvinceOptions([]);
setCertificationLevelOptions([]);
2026-03-26 09:29:55 +00:00
setChannelAttributeOptions([]);
setInternalAttributeOptions([]);
setNextChannelCode("");
2026-03-20 08:39:07 +00:00
}
}
}
2026-04-01 09:24:06 +00:00
void loadMetaOptions();
2026-03-20 08:39:07 +00:00
return () => {
cancelled = true;
};
2026-04-01 09:24:06 +00:00
}, [loadMeta]);
2026-03-20 08:39:07 +00:00
useEffect(() => {
let cancelled = false;
async function loadExpansionData() {
try {
const data = await getExpansionOverview(keyword);
if (cancelled) {
return;
}
setSalesData(data.salesItems ?? []);
setChannelData(data.channelItems ?? []);
setSelectedItem(null);
} catch {
if (!cancelled) {
setSalesData([]);
setChannelData([]);
setSelectedItem(null);
}
}
}
void loadExpansionData();
return () => {
cancelled = true;
};
}, [keyword, refreshTick]);
const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? [];
2026-03-26 09:29:55 +00:00
useEffect(() => {
if (selectedItem?.type === "sales") {
setSalesDetailTab("projects");
} else if (selectedItem?.type === "channel") {
setChannelDetailTab("projects");
}
}, [selectedItem]);
2026-03-20 08:39:07 +00:00
const handleSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
setSalesForm((current) => ({ ...current, [key]: value }));
2026-03-27 09:05:41 +00:00
if (key in salesCreateFieldErrors) {
setSalesCreateFieldErrors((current) => {
const next = { ...current };
delete next[key as SalesCreateField];
return next;
});
}
2026-03-20 08:39:07 +00:00
};
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
setChannelForm((current) => ({ ...current, [key]: value }));
2026-03-27 09:05:41 +00:00
if (key in channelCreateFieldErrors) {
setChannelCreateFieldErrors((current) => {
const next = { ...current };
delete next[key as ChannelField];
return next;
});
}
2026-03-20 08:39:07 +00:00
};
const handleEditSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
setEditSalesForm((current) => ({ ...current, [key]: value }));
2026-03-27 09:05:41 +00:00
if (key in salesEditFieldErrors) {
setSalesEditFieldErrors((current) => {
const next = { ...current };
delete next[key as SalesCreateField];
return next;
});
}
2026-03-20 08:39:07 +00:00
};
const handleEditChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
setEditChannelForm((current) => ({ ...current, [key]: value }));
2026-03-27 09:05:41 +00:00
if (key in channelEditFieldErrors) {
setChannelEditFieldErrors((current) => {
const next = { ...current };
delete next[key as ChannelField];
return next;
});
}
2026-03-20 08:39:07 +00:00
};
2026-03-26 09:29:55 +00:00
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 };
});
2026-03-27 09:05:41 +00:00
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));
2026-03-26 09:29:55 +00:00
};
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()],
};
});
2026-03-20 08:39:07 +00:00
};
const resetCreateState = () => {
setCreateOpen(false);
setCreateError("");
2026-03-27 09:05:41 +00:00
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
2026-03-20 08:39:07 +00:00
setSalesForm(defaultSalesForm);
setChannelForm(defaultChannelForm);
2026-04-01 09:24:06 +00:00
setCreateCityOptions([]);
2026-03-20 08:39:07 +00:00
};
const resetEditState = () => {
setEditOpen(false);
setEditError("");
2026-03-27 09:05:41 +00:00
setSalesEditFieldErrors({});
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
2026-03-20 08:39:07 +00:00
setEditSalesForm(defaultSalesForm);
setEditChannelForm(defaultChannelForm);
2026-04-01 09:24:06 +00:00
setEditCityOptions([]);
2026-03-20 08:39:07 +00:00
};
2026-04-01 09:24:06 +00:00
const handleOpenCreate = async () => {
2026-03-20 08:39:07 +00:00
setCreateError("");
2026-03-27 09:05:41 +00:00
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
2026-04-01 09:24:06 +00:00
try {
await loadMeta();
} catch {}
setCreateCityOptions([]);
2026-03-20 08:39:07 +00:00
setCreateOpen(true);
};
2026-04-01 09:24:06 +00:00
const handleOpenEdit = async () => {
2026-03-20 08:39:07 +00:00
if (!selectedItem) {
return;
}
setEditError("");
2026-04-01 09:24:06 +00:00
let latestIndustryOptions = industryOptions;
let latestCertificationLevelOptions = certificationLevelOptions;
try {
const meta = await loadMeta();
latestIndustryOptions = meta.industryOptions ?? [];
latestCertificationLevelOptions = meta.certificationLevelOptions ?? [];
} catch {}
2026-03-20 08:39:07 +00:00
if (selectedItem.type === "sales") {
2026-03-27 09:05:41 +00:00
setSalesEditFieldErrors({});
2026-03-20 08:39:07 +00:00
setEditSalesForm({
2026-03-26 09:29:55 +00:00
employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "",
2026-03-20 08:39:07 +00:00
candidateName: selectedItem.name ?? "",
2026-03-26 09:29:55 +00:00
officeName: selectedItem.officeCode ?? "",
2026-03-20 08:39:07 +00:00
mobile: selectedItem.phone === "无" ? "" : selectedItem.phone ?? "",
2026-03-26 09:29:55 +00:00
targetDept: selectedItem.dept === "无" ? "" : selectedItem.dept ?? selectedItem.targetDept ?? "",
industry: selectedItem.industryCode ?? "",
2026-03-20 08:39:07 +00:00
title: selectedItem.title === "无" ? "" : selectedItem.title ?? "",
intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasExp),
2026-03-26 09:29:55 +00:00
employmentStatus: selectedItem.active ? "active" : "left",
2026-03-20 08:39:07 +00:00
});
} else {
2026-03-27 09:05:41 +00:00
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
2026-04-01 09:24:06 +00:00
const normalizedProvinceName = selectedItem.province === "无" ? "" : selectedItem.province ?? "";
const normalizedCityName = selectedItem.city === "无" ? "" : selectedItem.city ?? "";
2026-03-27 09:05:41 +00:00
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
2026-03-20 08:39:07 +00:00
setEditChannelForm({
2026-03-26 09:29:55 +00:00
channelCode: selectedItem.channelCode ?? "",
2026-03-20 08:39:07 +00:00
channelName: selectedItem.name ?? "",
2026-04-01 09:24:06 +00:00
province: normalizedProvinceName,
city: normalizedCityName,
2026-03-26 09:29:55 +00:00
officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "",
2026-04-01 09:24:06 +00:00
channelIndustry: normalizeMultiOptionValues(
selectedItem.channelIndustryCode ?? (selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry),
latestIndustryOptions,
),
certificationLevel: normalizeOptionValue(
selectedItem.certificationLevel === "无" ? "" : selectedItem.certificationLevel,
latestCertificationLevelOptions,
) || "",
2026-03-20 08:39:07 +00:00
annualRevenue: selectedItem.annualRevenue ? Number(selectedItem.annualRevenue) : undefined,
staffSize: selectedItem.size ?? undefined,
2026-03-26 09:29:55 +00:00
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasDesktopExp),
2026-03-27 09:05:41 +00:00
channelAttribute: parsedChannelAttributes.values,
channelAttributeCustom: parsedChannelAttributes.customText,
internalAttribute: parsedInternalAttributes.values,
2026-03-20 08:39:07 +00:00
stage: selectedItem.stageCode ?? "initial_contact",
remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "",
2026-03-26 09:29:55 +00:00
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()],
2026-03-20 08:39:07 +00:00
});
2026-04-01 09:24:06 +00:00
void loadCityOptions(normalizedProvinceName, true);
2026-03-20 08:39:07 +00:00
}
setEditOpen(true);
};
const handleCreateSubmit = async () => {
if (submitting) {
return;
}
setCreateError("");
2026-03-27 09:05:41 +00:00
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;
}
}
setSubmitting(true);
2026-03-20 08:39:07 +00:00
try {
if (activeTab === "sales") {
2026-03-27 09:05:41 +00:00
await createSalesExpansion(normalizeSalesPayload(salesForm));
2026-03-20 08:39:07 +00:00
} else {
2026-03-27 09:05:41 +00:00
await createChannelExpansion(normalizeChannelPayload(channelForm));
2026-03-20 08:39:07 +00:00
}
resetCreateState();
setRefreshTick((current) => current + 1);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "新增失败");
} finally {
setSubmitting(false);
}
};
const handleEditSubmit = async () => {
if (!selectedItem || submitting) {
return;
}
setEditError("");
2026-03-27 09:05:41 +00:00
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);
2026-03-20 08:39:07 +00:00
try {
if (selectedItem.type === "sales") {
2026-03-27 09:05:41 +00:00
await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm));
2026-03-20 08:39:07 +00:00
} else {
2026-03-27 09:05:41 +00:00
await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm));
2026-03-20 08:39:07 +00:00
}
resetEditState();
setSelectedItem(null);
setRefreshTick((current) => current + 1);
} catch (error) {
setEditError(error instanceof Error ? error.message : "编辑失败");
} finally {
setSubmitting(false);
}
};
const renderEmpty = () => (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-20 08:39:07 +00:00
</div>
);
2026-03-26 09:29:55 +00:00
const renderFollowUpTimeline = () => {
if (followUpRecords.length <= 0) {
return (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</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>
);
};
2026-03-20 08:39:07 +00:00
const handleTabChange = (tab: ExpansionTab) => {
setActiveTab(tab);
setSelectedItem(null);
};
const renderSalesForm = (
form: CreateSalesExpansionPayload,
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
2026-03-27 09:05:41 +00:00
fieldErrors?: Partial<Record<SalesCreateField, string>>,
2026-03-20 08:39:07 +00:00
) => (
2026-03-26 09:29:55 +00:00
<div className="crm-form-grid">
2026-03-20 08:39:07 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / <RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.officeName || ""}
placeholder="请选择"
sheetTitle="代表处 / 办事处"
options={[
{ value: "", label: "请选择" },
...officeOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => onChange("officeName", value || undefined)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.officeName ? <p className="text-xs text-rose-500">{fieldErrors.officeName}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<input
value={form.targetDept || ""}
onChange={(e) => onChange("targetDept", e.target.value)}
placeholder="办事处/行业系统部/地市"
2026-03-27 09:05:41 +00:00
className={getFieldInputClass(Boolean(fieldErrors?.targetDept))}
2026-03-26 09:29:55 +00:00
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.targetDept ? <p className="text-xs text-rose-500">{fieldErrors.targetDept}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.industry || ""}
placeholder="请选择"
sheetTitle="所属行业"
options={[
{ value: "", label: "请选择" },
...industryOptions.map((option) => ({
value: option.value ?? "",
label: option.label || "无",
})),
]}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => onChange("industry", value || undefined)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.industry ? <p className="text-xs text-rose-500">{fieldErrors.industry}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.intentLevel}
sheetTitle="合作意向"
options={[
{ value: "high", label: "高" },
{ value: "medium", label: "中" },
{ value: "low", label: "低" },
]}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => onChange("intentLevel", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.employmentStatus}
sheetTitle="销售是否在职"
options={[
{ value: "active", label: "是" },
{ value: "left", label: "否" },
]}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => onChange("employmentStatus", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.employmentStatus ? <p className="text-xs text-rose-500">{fieldErrors.employmentStatus}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
2026-03-26 09:29:55 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
2026-03-20 08:39:07 +00:00
<input type="checkbox" checked={Boolean(form.hasDesktopExp)} onChange={(e) => onChange("hasDesktopExp", e.target.checked)} />
</label>
<label className="space-y-2 sm:col-span-2">
2026-03-26 09:29:55 +00:00
<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>
2026-03-20 08:39:07 +00:00
</label>
</div>
);
const renderChannelForm = (
form: CreateChannelExpansionPayload,
onChange: <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => void,
2026-03-26 09:29:55 +00:00
isEdit = false,
2026-03-27 09:05:41 +00:00
fieldErrors?: Partial<Record<ChannelField, string>>,
invalidContactRows: number[] = [],
2026-04-01 09:24:06 +00:00
) => {
const cityOptions = isEdit ? editCityOptions : createCityOptions;
const cityDisabled = !form.province?.trim();
return (
2026-03-26 09:29:55 +00:00
<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>
2026-03-20 08:39:07 +00:00
<label className="space-y-2 sm:col-span-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-04-01 09:24:06 +00:00
<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);
}}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
2026-04-01 09:24:06 +00:00
<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>
2026-03-20 08:39:07 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-04-01 09:24:06 +00:00
<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)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
2026-04-01 09:24:06 +00:00
<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>
2026-03-20 08:39:07 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<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}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
value={form.intentLevel || "medium"}
sheetTitle="合作意向"
options={[
{ value: "high", label: "高" },
{ value: "medium", label: "中" },
{ value: "low", label: "低" },
]}
2026-03-27 09:05:41 +00:00
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" : "",
)}
2026-03-26 09:29:55 +00:00
onChange={(value) => onChange("intentLevel", value)}
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
2026-03-27 09:05:41 +00:00
multiple
value={form.channelAttribute || []}
2026-03-26 09:29:55 +00:00
placeholder="请选择"
sheetTitle="渠道属性"
2026-03-27 09:05:41 +00:00
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", "");
}
}}
2026-03-26 09:29:55 +00:00
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.channelAttribute ? <p className="text-xs text-rose-500">{fieldErrors.channelAttribute}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
2026-03-27 09:05:41 +00:00
{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}
2026-03-20 08:39:07 +00:00
<label className="space-y-2">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<AdaptiveSelect
2026-03-27 09:05:41 +00:00
multiple
value={form.internalAttribute || []}
2026-03-26 09:29:55 +00:00
placeholder="请选择"
sheetTitle="新华三内部属性"
2026-03-27 09:05:41 +00:00
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)}
2026-03-26 09:29:55 +00:00
/>
2026-03-27 09:05:41 +00:00
{fieldErrors?.internalAttribute ? <p className="text-xs text-rose-500">{fieldErrors.internalAttribute}</p> : null}
2026-03-20 08:39:07 +00:00
</label>
2026-03-26 09:29:55 +00:00
<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">
2026-03-27 09:05:41 +00:00
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200"><RequiredMark /></span>
2026-03-26 09:29:55 +00:00
<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">
2026-03-27 09:05:41 +00:00
<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">
2026-03-26 09:29:55 +00:00
</button>
</div>
))}
</div>
2026-03-27 09:05:41 +00:00
{fieldErrors?.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
2026-03-26 09:29:55 +00:00
</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>
2026-03-20 08:39:07 +00:00
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
2026-03-26 09:29:55 +00:00
<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" />
2026-03-20 08:39:07 +00:00
</label>
</div>
);
2026-04-01 09:24:06 +00:00
};
2026-03-19 06:23:03 +00:00
return (
2026-03-26 09:29:55 +00:00
<div className="crm-page-stack">
2026-03-27 09:05:41 +00:00
<header className="crm-page-header">
<div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle hidden sm:block"></p>
</div>
2026-03-20 08:39:07 +00:00
<button
onClick={handleOpenCreate}
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
2026-03-20 08:39:07 +00:00
>
2026-03-27 09:05:41 +00:00
<Plus className="crm-icon-md" />
2026-03-19 06:23:03 +00:00
<span className="hidden sm:inline"></span>
</button>
</header>
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
2026-03-19 06:23:03 +00:00
<button
2026-03-20 08:39:07 +00:00
onClick={() => handleTabChange("sales")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
2026-03-20 08:39:07 +00:00
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"
2026-03-19 06:23:03 +00:00
}`}
>
</button>
<button
2026-03-20 08:39:07 +00:00
onClick={() => handleTabChange("channel")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
2026-03-20 08:39:07 +00:00
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"
2026-03-19 06:23:03 +00:00
}`}
>
</button>
</div>
2026-03-20 08:39:07 +00:00
<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" />
2026-03-19 06:23:03 +00:00
<input
type="text"
2026-03-26 09:29:55 +00:00
placeholder="搜索工号、姓名、渠道名称、行业..."
2026-03-20 08:39:07 +00:00
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
2026-03-26 09:29:55 +00:00
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"
2026-03-19 06:23:03 +00:00
/>
</div>
2026-03-26 09:29:55 +00:00
<div className="crm-list-stack">
2026-03-19 06:23:03 +00:00
{activeTab === "sales" ? (
2026-03-20 08:39:07 +00:00
salesData.length > 0 ? (
salesData.map((item, i) => (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
2026-03-20 08:39:07 +00:00
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
2026-03-26 09:29:55 +00:00
key={item.id}
onClick={() => setSelectedItem(item)}
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
2026-03-26 09:29:55 +00:00
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<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>
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{item.active ? "在职" : "离职"}
</span>
</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>
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<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)}
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
</div>
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={`${detailBadgeClass} px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px]`}>
2026-03-27 09:05:41 +00:00
<ChevronRight className="crm-icon-sm" />
2026-03-26 09:29:55 +00:00
</button>
</div>
2026-03-20 08:39:07 +00:00
</motion.div>
))
) : renderEmpty()
) : channelData.length > 0 ? (
2026-03-19 06:23:03 +00:00
channelData.map((item, i) => (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
2026-03-19 06:23:03 +00:00
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
2026-03-19 06:23:03 +00:00
key={item.id}
onClick={() => setSelectedItem(item)}
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
2026-03-19 06:23:03 +00:00
>
2026-03-26 09:29:55 +00:00
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<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">
2026-04-01 09:24:06 +00:00
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
2026-03-26 09:29:55 +00:00
</p>
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
{item.intent ? `${item.intent}意向` : "未评估"}
2026-03-19 06:23:03 +00:00
</span>
</div>
2026-03-26 09:29:55 +00:00
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
2026-03-19 06:23:03 +00:00
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
2026-03-26 09:29:55 +00:00
<span className="text-slate-400 dark:text-slate-500">:</span>
{item.establishedDate || "无"}
2026-03-19 06:23:03 +00:00
</div>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
2026-03-26 09:29:55 +00:00
<span className="text-slate-400 dark:text-slate-500">:</span>
{formatRelatedProjectAmount(item.relatedProjects)}
2026-03-19 06:23:03 +00:00
</div>
</div>
2026-03-20 08:39:07 +00:00
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
2026-03-26 09:29:55 +00:00
<button type="button" className={detailBadgeClass}>
2026-03-27 09:05:41 +00:00
<ChevronRight className="crm-icon-sm" />
2026-03-26 09:29:55 +00:00
</button>
2026-03-19 06:23:03 +00:00
</div>
</motion.div>
))
2026-03-20 08:39:07 +00:00
) : renderEmpty()}
2026-03-19 06:23:03 +00:00
</div>
2026-03-20 08:39:07 +00:00
<AnimatePresence>
{createOpen && (
<ModalShell
title={`新增${activeTab === "sales" ? "销售人员拓展" : "渠道拓展"}`}
subtitle="支持电脑和手机填写,提交后自动刷新列表。"
onClose={resetCreateState}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
2026-03-27 09:05:41 +00:00
<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>
2026-03-20 08:39:07 +00:00
</div>
)}
>
2026-03-27 09:05:41 +00:00
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
{createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
2026-03-20 08:39:07 +00:00
</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">
2026-03-27 09:05:41 +00:00
<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>
2026-03-20 08:39:07 +00:00
</div>
)}
>
2026-03-27 09:05:41 +00:00
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
2026-03-20 08:39:07 +00:00
</ModalShell>
)}
</AnimatePresence>
2026-03-19 06:23:03 +00:00
<AnimatePresence>
{selectedItem && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedItem(null)}
2026-03-20 08:39:07 +00:00
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" : ""
}`}
2026-03-19 06:23:03 +00:00
/>
<motion.div
2026-03-20 08:39:07 +00:00
initial={{ x: "100%", y: 0 }}
animate={{ x: 0, y: 0 }}
exit={{ x: "100%", y: 0 }}
2026-03-19 06:23:03 +00:00
transition={{ type: "spring", damping: 25, stiffness: 200 }}
2026-03-26 09:29:55 +00:00
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 ${
2026-03-20 08:39:07 +00:00
hasForegroundModal ? "pointer-events-none opacity-20" : ""
}`}
2026-03-19 06:23:03 +00:00
>
2026-03-20 08:39:07 +00:00
<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" />
2026-03-26 09:29:55 +00:00
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{selectedItem.type === "sales" ? "销售拓展详情" : "渠道拓展详情"}</h2>
2026-03-20 08:39:07 +00:00
</div>
2026-03-27 09:05:41 +00:00
<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" />
2026-03-19 06:23:03 +00:00
</button>
</div>
2026-03-26 09:29:55 +00:00
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">
<div className="crm-modal-stack">
2026-03-19 06:23:03 +00:00
<div>
2026-03-26 09:29:55 +00:00
{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">
2026-03-20 08:39:07 +00:00
{selectedItem.type === "sales"
2026-03-26 09:29:55 +00:00
? `${selectedItem.officeName || "无"} · ${selectedItem.dept || "无"} · ${selectedItem.title || "无"}`
2026-04-01 09:24:06 +00:00
: `${selectedItem.province || "无"} · ${selectedItem.city || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.certificationLevel || "无"}`}
2026-03-19 06:23:03 +00:00
</p>
2026-03-26 09:29:55 +00:00
<div className="mt-3 flex flex-wrap gap-2">
2026-03-20 08:39:07 +00:00
{selectedItem.type === "sales" ? (
2026-03-26 09:29:55 +00:00
<span className={`crm-pill ${selectedItem.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
2026-03-20 08:39:07 +00:00
{selectedItem.active ? "在职" : "离职"}
2026-03-19 06:23:03 +00:00
</span>
2026-03-26 09:29:55 +00:00
) : null}
2026-03-19 06:23:03 +00:00
</div>
</div>
2026-03-26 09:29:55 +00:00
<div className="crm-section-stack">
2026-03-20 08:39:07 +00:00
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
2026-03-27 09:05:41 +00:00
<FileText className="crm-icon-md text-violet-500" />
2026-03-19 06:23:03 +00:00
</h4>
2026-03-26 09:29:55 +00:00
<div className="crm-detail-grid text-sm sm:grid-cols-2">
2026-03-20 08:39:07 +00:00
{selectedItem.type === "sales" ? (
2026-03-19 06:23:03 +00:00
<>
2026-03-26 09:29:55 +00:00
<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 ? "是" : "否"} />
2026-03-19 06:23:03 +00:00
</>
) : (
<>
2026-03-26 09:29:55 +00:00
<DetailItem label="编码" value={selectedItem.channelCode || "无"} />
<DetailItem label="省份" value={selectedItem.province || "无"} />
2026-04-01 09:24:06 +00:00
<DetailItem label="市" value={selectedItem.city || "无"} />
<DetailItem label="认证级别" value={selectedItem.certificationLevel || "无"} />
2026-03-26 09:29:55 +00:00
<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 || "无"} />
2026-03-19 06:23:03 +00:00
</>
)}
2026-03-26 09:29:55 +00:00
{selectedItem.type !== "sales" ? <DetailItem label="备注说明" value={selectedItem.notes || "无"} className="sm:col-span-2" /> : null}
2026-03-19 06:23:03 +00:00
</div>
</div>
2026-03-26 09:29:55 +00:00
{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>
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</div>
)}
</div>
) : (
<div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
2026-03-27 09:05:41 +00:00
<Clock className="crm-icon-md text-violet-500" />
2026-03-26 09:29:55 +00:00
</h4>
{renderFollowUpTimeline()}
</div>
)}
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
) : (
<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))}`} />
2026-03-20 08:39:07 +00:00
</div>
2026-03-26 09:29:55 +00:00
))}
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</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>
) : (
2026-03-27 09:05:41 +00:00
<div className="crm-empty-panel">
2026-03-26 09:29:55 +00:00
</div>
)
) : null}
{channelDetailTab === "followups" ? (
renderFollowUpTimeline()
) : null}
</div>
)}
2026-03-19 06:23:03 +00:00
</div>
2026-03-26 09:29:55 +00:00
<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">
<div className="flex">
2026-03-27 09:05:41 +00:00
<button onClick={handleOpenEdit} className="crm-btn-sm crm-btn-secondary flex-1">
2026-03-19 06:23:03 +00:00
</button>
</div>
</div>
2026-03-26 09:29:55 +00:00
</div>
2026-03-19 06:23:03 +00:00
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
2026-03-26 09:29:55 +00:00
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 : "无";
}