2026-03-20 08:39:07 +00:00
|
|
|
|
import { 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,
|
|
|
|
|
|
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";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
|
|
|
|
|
type ExpansionTab = "sales" | "channel";
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 transition-all hover:border-violet-200 hover:bg-violet-100 dark:border-violet-500/20 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/15";
|
|
|
|
|
|
|
|
|
|
|
|
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-03-26 09:29:55 +00:00
|
|
|
|
officeAddress: "",
|
|
|
|
|
|
channelIndustry: "",
|
|
|
|
|
|
contactEstablishedDate: "",
|
|
|
|
|
|
intentLevel: "medium",
|
|
|
|
|
|
hasDesktopExp: false,
|
|
|
|
|
|
channelAttribute: "",
|
|
|
|
|
|
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
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function ModalShell({
|
|
|
|
|
|
title,
|
|
|
|
|
|
subtitle,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
children,
|
|
|
|
|
|
footer,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
subtitle: string;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
footer: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="fixed inset-0 z-[70] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
exit={{ opacity: 0, y: 20 }}
|
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-19 06:23:03 +00:00
|
|
|
|
export default function Expansion() {
|
2026-03-27 04:22:00 +00:00
|
|
|
|
const location = useLocation();
|
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[]>([]);
|
|
|
|
|
|
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
|
|
|
|
|
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
|
|
|
|
|
const [nextChannelCode, setNextChannelCode] = useState("");
|
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-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-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;
|
|
|
|
|
|
|
|
|
|
|
|
async function loadMeta() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getExpansionMeta();
|
|
|
|
|
|
if (!cancelled) {
|
2026-03-26 09:29:55 +00:00
|
|
|
|
setOfficeOptions(data.officeOptions ?? []);
|
|
|
|
|
|
setIndustryOptions(data.industryOptions ?? []);
|
|
|
|
|
|
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([]);
|
|
|
|
|
|
setChannelAttributeOptions([]);
|
|
|
|
|
|
setInternalAttributeOptions([]);
|
|
|
|
|
|
setNextChannelCode("");
|
2026-03-20 08:39:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void loadMeta();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
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 }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
|
|
|
|
|
setChannelForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
|
|
|
|
|
|
setEditSalesForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
|
|
|
|
|
setEditChannelForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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("");
|
|
|
|
|
|
setSalesForm(defaultSalesForm);
|
|
|
|
|
|
setChannelForm(defaultChannelForm);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetEditState = () => {
|
|
|
|
|
|
setEditOpen(false);
|
|
|
|
|
|
setEditError("");
|
|
|
|
|
|
setEditSalesForm(defaultSalesForm);
|
|
|
|
|
|
setEditChannelForm(defaultChannelForm);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenCreate = () => {
|
|
|
|
|
|
setCreateError("");
|
|
|
|
|
|
setCreateOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenEdit = () => {
|
|
|
|
|
|
if (!selectedItem) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setEditError("");
|
|
|
|
|
|
if (selectedItem.type === "sales") {
|
|
|
|
|
|
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 {
|
|
|
|
|
|
setEditChannelForm({
|
2026-03-26 09:29:55 +00:00
|
|
|
|
channelCode: selectedItem.channelCode ?? "",
|
2026-03-20 08:39:07 +00:00
|
|
|
|
channelName: selectedItem.name ?? "",
|
|
|
|
|
|
province: selectedItem.province === "无" ? "" : selectedItem.province ?? "",
|
2026-03-26 09:29:55 +00:00
|
|
|
|
officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "",
|
|
|
|
|
|
channelIndustry: selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry ?? "",
|
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),
|
|
|
|
|
|
channelAttribute: selectedItem.channelAttribute === "无" ? "" : selectedItem.channelAttribute ?? "",
|
|
|
|
|
|
internalAttribute: selectedItem.internalAttribute === "无" ? "" : selectedItem.internalAttribute ?? "",
|
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
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreateSubmit = async () => {
|
|
|
|
|
|
if (submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setCreateError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (activeTab === "sales") {
|
|
|
|
|
|
await createSalesExpansion({
|
|
|
|
|
|
...salesForm,
|
2026-03-26 09:29:55 +00:00
|
|
|
|
targetDept: salesForm.targetDept?.trim() || undefined,
|
2026-03-20 08:39:07 +00:00
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await createChannelExpansion({
|
|
|
|
|
|
...channelForm,
|
|
|
|
|
|
annualRevenue: channelForm.annualRevenue || undefined,
|
|
|
|
|
|
staffSize: channelForm.staffSize || undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resetCreateState();
|
|
|
|
|
|
setRefreshTick((current) => current + 1);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setCreateError(error instanceof Error ? error.message : "新增失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditSubmit = async () => {
|
|
|
|
|
|
if (!selectedItem || submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setEditError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (selectedItem.type === "sales") {
|
|
|
|
|
|
await updateSalesExpansion(selectedItem.id, {
|
|
|
|
|
|
...editSalesForm,
|
2026-03-26 09:29:55 +00:00
|
|
|
|
targetDept: editSalesForm.targetDept?.trim() || undefined,
|
2026-03-20 08:39:07 +00:00
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await updateChannelExpansion(selectedItem.id, {
|
|
|
|
|
|
...editChannelForm,
|
|
|
|
|
|
annualRevenue: editChannelForm.annualRevenue || undefined,
|
|
|
|
|
|
staffSize: editChannelForm.staffSize || undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resetEditState();
|
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
|
|
setRefreshTick((current) => current + 1);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setEditError(error instanceof Error ? error.message : "编辑失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderEmpty = () => (
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-empty-state rounded-2xl border border-slate-100 bg-white p-10 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
暂无
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
|
const renderFollowUpTimeline = () => {
|
|
|
|
|
|
if (followUpRecords.length <= 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无跟进记录
|
|
|
|
|
|
</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-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-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">工号</span>
|
|
|
|
|
|
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">代表处 / 办事处</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.officeName || ""}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="代表处 / 办事处"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "", label: "请选择" },
|
|
|
|
|
|
...officeOptions.map((option) => ({
|
|
|
|
|
|
value: option.value ?? "",
|
|
|
|
|
|
label: option.label || "无",
|
|
|
|
|
|
})),
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("officeName", value || undefined)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">姓名</span>
|
|
|
|
|
|
<input value={form.candidateName} onChange={(e) => onChange("candidateName", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">联系方式</span>
|
|
|
|
|
|
<input value={form.mobile} onChange={(e) => onChange("mobile", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">所属部门</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={form.targetDept || ""}
|
|
|
|
|
|
onChange={(e) => onChange("targetDept", e.target.value)}
|
|
|
|
|
|
placeholder="办事处/行业系统部/地市"
|
|
|
|
|
|
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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">所属行业</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.industry || ""}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="所属行业"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "", label: "请选择" },
|
|
|
|
|
|
...industryOptions.map((option) => ({
|
|
|
|
|
|
value: option.value ?? "",
|
|
|
|
|
|
label: option.label || "无",
|
|
|
|
|
|
})),
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("industry", value || undefined)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">职务</span>
|
|
|
|
|
|
<input value={form.title} onChange={(e) => onChange("title", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">合作意向</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.intentLevel}
|
|
|
|
|
|
sheetTitle="合作意向"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "high", label: "高" },
|
|
|
|
|
|
{ value: "medium", label: "中" },
|
|
|
|
|
|
{ value: "low", label: "低" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("intentLevel", value)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">销售是否在职</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.employmentStatus}
|
|
|
|
|
|
sheetTitle="销售是否在职"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "active", label: "是" },
|
|
|
|
|
|
{ value: "left", label: "否" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("employmentStatus", value)}
|
|
|
|
|
|
/>
|
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-20 08:39:07 +00:00
|
|
|
|
) => (
|
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">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称</span>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<input value={form.channelName} onChange={(e) => onChange("channelName", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份</span>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<input value={form.province} onChange={(e) => onChange("province", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">办公地址</span>
|
|
|
|
|
|
<input value={form.officeAddress || ""} onChange={(e) => onChange("officeAddress", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">聚焦行业</span>
|
|
|
|
|
|
<input value={form.channelIndustry || ""} onChange={(e) => onChange("channelIndustry", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">年营收</span>
|
|
|
|
|
|
<input type="number" value={form.annualRevenue ?? ""} onChange={(e) => onChange("annualRevenue", e.target.value ? Number(e.target.value) : undefined)} 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">人员规模</span>
|
|
|
|
|
|
<input type="number" value={form.staffSize ?? ""} onChange={(e) => onChange("staffSize", e.target.value ? Number(e.target.value) : undefined)} 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建立联系时间</span>
|
|
|
|
|
|
<input type="date" value={form.contactEstablishedDate || ""} onChange={(e) => onChange("contactEstablishedDate", 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>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">合作意向</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.intentLevel || "medium"}
|
|
|
|
|
|
sheetTitle="合作意向"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "high", label: "高" },
|
|
|
|
|
|
{ value: "medium", label: "中" },
|
|
|
|
|
|
{ value: "low", label: "低" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("intentLevel", value)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道属性</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.channelAttribute || ""}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="渠道属性"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "", label: "请选择" },
|
|
|
|
|
|
...channelAttributeOptions.map((option) => ({
|
|
|
|
|
|
value: option.value ?? "",
|
|
|
|
|
|
label: option.label || "无",
|
|
|
|
|
|
})),
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("channelAttribute", value || undefined)}
|
|
|
|
|
|
/>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新华三内部属性</span>
|
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
|
value={form.internalAttribute || ""}
|
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
|
sheetTitle="新华三内部属性"
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "", label: "请选择" },
|
|
|
|
|
|
...internalAttributeOptions.map((option) => ({
|
|
|
|
|
|
value: option.value ?? "",
|
|
|
|
|
|
label: option.label || "无",
|
|
|
|
|
|
})),
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => onChange("internalAttribute", value || undefined)}
|
|
|
|
|
|
/>
|
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">
|
|
|
|
|
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">人员信息</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="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
<input value={contact.mobile || ""} onChange={(e) => handleChannelContactChange(index, "mobile", e.target.value, isEdit)} placeholder="联系电话" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
<input value={contact.title || ""} onChange={(e) => handleChannelContactChange(index, "title", e.target.value, isEdit)} placeholder="职位" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
<button type="button" onClick={() => removeChannelContact(index, isEdit)} className="rounded-lg border border-rose-200 px-3 py-2 text-sm font-medium text-rose-500 hover:bg-rose-50 dark:border-rose-900/50 dark:hover:bg-rose-500/10">
|
|
|
|
|
|
删除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</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-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<div className="crm-page-stack">
|
|
|
|
|
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">拓展管理</h1>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleOpenCreate}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-btn-sm flex items-center gap-2 rounded-xl bg-violet-600 text-white shadow-sm transition-all hover:bg-violet-700 active:scale-95"
|
2026-03-20 08:39:07 +00:00
|
|
|
|
>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
<span className="hidden sm:inline">新增</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="flex rounded-xl border border-slate-200/50 bg-slate-100 p-1 backdrop-blur-sm dark:border-slate-800/50 dark:bg-slate-900/50">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<button
|
2026-03-20 08:39:07 +00:00
|
|
|
|
onClick={() => handleTabChange("sales")}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all 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")}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all 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={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
transition={{ delay: i * 0.05 }}
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
onClick={() => setSelectedItem(item)}
|
|
|
|
|
|
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<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]`}>
|
|
|
|
|
|
查看详情
|
|
|
|
|
|
<ChevronRight className="h-3 w-3" />
|
|
|
|
|
|
</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={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: i * 0.05 }}
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
onClick={() => setSelectedItem(item)}
|
2026-03-26 09:29:55 +00:00
|
|
|
|
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-all 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">
|
|
|
|
|
|
{item.province || "无"} · {item.internalAttribute || "无"}
|
|
|
|
|
|
</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}>
|
|
|
|
|
|
查看详情
|
|
|
|
|
|
<ChevronRight className="h-3 w-3" />
|
|
|
|
|
|
</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-26 09:29:55 +00:00
|
|
|
|
<button onClick={resetCreateState} className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">取消</button>
|
|
|
|
|
|
<button onClick={() => void handleCreateSubmit()} disabled={submitting} className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : "确认新增"}</button>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange) : renderChannelForm(channelForm, handleChannelChange)}
|
|
|
|
|
|
{createError ? <div className="mt-4 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{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">
|
2026-03-26 09:29:55 +00:00
|
|
|
|
<button onClick={resetEditState} className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">取消</button>
|
|
|
|
|
|
<button onClick={() => void handleEditSubmit()} disabled={submitting} className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "保存中..." : "保存修改"}</button>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-03-26 09:29:55 +00:00
|
|
|
|
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange) : renderChannelForm(editChannelForm, handleEditChannelChange, true)}
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{editError ? <div className="mt-4 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{editError}</div> : null}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
|
</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 || "无"}`
|
|
|
|
|
|
: `${selectedItem.province || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.officeAddress || "无"}`}
|
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-19 06:23:03 +00:00
|
|
|
|
<FileText className="h-4 w-4 text-violet-500" />
|
|
|
|
|
|
基本信息
|
|
|
|
|
|
</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 || "无"} />
|
|
|
|
|
|
<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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无关联项目
|
|
|
|
|
|
</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="h-4 w-4 text-violet-500" />
|
|
|
|
|
|
跟进记录
|
|
|
|
|
|
</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
|
|
|
|
) : (
|
|
|
|
|
|
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无关联项目
|
|
|
|
|
|
</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-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
暂无人员信息
|
|
|
|
|
|
</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">
|
|
|
|
|
|
<button onClick={handleOpenEdit} className="crm-btn-sm flex-1 rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
|
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 : "无";
|
|
|
|
|
|
}
|