2026-03-20 08:39:07 +00:00
|
|
|
|
import { useEffect, useState, type ReactNode } from "react";
|
|
|
|
|
|
import { Search, Plus, ChevronRight, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, Link as LinkIcon } from "lucide-react";
|
2026-03-19 06:23:03 +00:00
|
|
|
|
import { motion, AnimatePresence } from "motion/react";
|
2026-03-20 08:39:07 +00:00
|
|
|
|
import { createOpportunity, createOpportunityFollowUp, getOpportunityOverview, updateOpportunity, type CreateOpportunityFollowUpPayload, type CreateOpportunityPayload, type OpportunityFollowUp, type OpportunityItem } from "@/lib/auth";
|
|
|
|
|
|
|
|
|
|
|
|
const stageOptions = ["全部", "初步沟通", "方案交流", "招投标", "商务谈判", "已成交"] as const;
|
|
|
|
|
|
|
|
|
|
|
|
const defaultForm: CreateOpportunityPayload = {
|
|
|
|
|
|
opportunityName: "",
|
|
|
|
|
|
customerName: "",
|
|
|
|
|
|
amount: 0,
|
|
|
|
|
|
expectedCloseDate: "",
|
|
|
|
|
|
confidencePct: 50,
|
|
|
|
|
|
stage: "初步沟通",
|
|
|
|
|
|
opportunityType: "新建",
|
|
|
|
|
|
productType: "VDI云桌面",
|
|
|
|
|
|
source: "主动开发",
|
|
|
|
|
|
pushedToOms: false,
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function toDateTimeLocalValue(date = new Date()) {
|
|
|
|
|
|
const timezoneOffset = date.getTimezoneOffset() * 60000;
|
|
|
|
|
|
return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 16);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const defaultFollowUpForm: CreateOpportunityFollowUpPayload = {
|
|
|
|
|
|
followUpType: "电话沟通",
|
|
|
|
|
|
content: "",
|
|
|
|
|
|
nextAction: "",
|
|
|
|
|
|
followUpTime: toDateTimeLocalValue(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function formatAmount(value?: number) {
|
|
|
|
|
|
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
|
|
|
|
|
return "0";
|
|
|
|
|
|
}
|
|
|
|
|
|
return new Intl.NumberFormat("zh-CN").format(Number(value));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
|
|
|
|
|
return {
|
|
|
|
|
|
opportunityName: item.name || "",
|
|
|
|
|
|
customerName: item.client || "",
|
|
|
|
|
|
amount: item.amount || 0,
|
|
|
|
|
|
expectedCloseDate: item.date || "",
|
|
|
|
|
|
confidencePct: item.confidence ?? 50,
|
|
|
|
|
|
stage: item.stage || "初步沟通",
|
|
|
|
|
|
opportunityType: item.type || "新建",
|
|
|
|
|
|
productType: item.product || "VDI云桌面",
|
|
|
|
|
|
source: item.source || "主动开发",
|
|
|
|
|
|
pushedToOms: Boolean(item.pushedToOms),
|
|
|
|
|
|
description: item.notes || "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 }} className="fixed inset-0 z-[80] p-0 sm:p-6">
|
|
|
|
|
|
<div className="mx-auto flex h-full w-full items-end sm:max-w-3xl sm:items-center">
|
|
|
|
|
|
<div className="flex h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:h-full sm:rounded-3xl">
|
|
|
|
|
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2>
|
|
|
|
|
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{subtitle}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={onClose} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
|
|
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
|
|
|
|
|
|
<div className="border-t border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">{footer}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
export default function Opportunities() {
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const [filter, setFilter] = useState<(typeof stageOptions)[number]>("全部");
|
|
|
|
|
|
const [keyword, setKeyword] = useState("");
|
|
|
|
|
|
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
|
|
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
|
|
|
|
const [followUpOpen, setFollowUpOpen] = useState(false);
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [followUpError, setFollowUpError] = useState("");
|
|
|
|
|
|
const [items, setItems] = useState<OpportunityItem[]>([]);
|
|
|
|
|
|
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
|
|
|
|
|
const [followUpForm, setFollowUpForm] = useState<CreateOpportunityFollowUpPayload>(defaultFollowUpForm);
|
|
|
|
|
|
const hasForegroundModal = createOpen || editOpen || followUpOpen;
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getOpportunityOverview(keyword, filter);
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setItems(data.items ?? []);
|
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setItems([]);
|
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void load();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [keyword, filter]);
|
|
|
|
|
|
|
|
|
|
|
|
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
|
|
|
|
|
const getConfidenceColor = (score: number) => {
|
|
|
|
|
|
if (score >= 80) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
|
|
|
|
|
|
if (score >= 50) return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
|
|
|
|
|
|
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
|
|
|
|
|
|
setForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleFollowUpChange = <K extends keyof CreateOpportunityFollowUpPayload>(key: K, value: CreateOpportunityFollowUpPayload[K]) => {
|
|
|
|
|
|
setFollowUpForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenCreate = () => {
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setForm(defaultForm);
|
|
|
|
|
|
setCreateOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetCreateState = () => {
|
|
|
|
|
|
setCreateOpen(false);
|
|
|
|
|
|
setEditOpen(false);
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setForm(defaultForm);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetFollowUpState = () => {
|
|
|
|
|
|
setFollowUpOpen(false);
|
|
|
|
|
|
setFollowUpError("");
|
|
|
|
|
|
setFollowUpForm({
|
|
|
|
|
|
...defaultFollowUpForm,
|
|
|
|
|
|
followUpTime: toDateTimeLocalValue(),
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const reload = async (preferredSelectedId?: number) => {
|
|
|
|
|
|
const data = await getOpportunityOverview(keyword, filter);
|
|
|
|
|
|
const nextItems = data.items ?? [];
|
|
|
|
|
|
setItems(nextItems);
|
|
|
|
|
|
if (preferredSelectedId) {
|
|
|
|
|
|
setSelectedItem(nextItems.find((item) => item.id === preferredSelectedId) ?? null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreateSubmit = async () => {
|
|
|
|
|
|
if (submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await createOpportunity(form);
|
|
|
|
|
|
await reload();
|
|
|
|
|
|
resetCreateState();
|
|
|
|
|
|
} catch (createError) {
|
|
|
|
|
|
setError(createError instanceof Error ? createError.message : "新增商机失败");
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenEdit = () => {
|
|
|
|
|
|
if (!selectedItem) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setForm(toFormFromItem(selectedItem));
|
|
|
|
|
|
setEditOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditSubmit = async () => {
|
|
|
|
|
|
if (!selectedItem || submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateOpportunity(selectedItem.id, form);
|
|
|
|
|
|
await reload(selectedItem.id);
|
|
|
|
|
|
resetCreateState();
|
|
|
|
|
|
} catch (updateError) {
|
|
|
|
|
|
setError(updateError instanceof Error ? updateError.message : "编辑商机失败");
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenFollowUp = () => {
|
|
|
|
|
|
if (!selectedItem) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setFollowUpError("");
|
|
|
|
|
|
setFollowUpForm({
|
|
|
|
|
|
followUpType: "电话沟通",
|
|
|
|
|
|
content: "",
|
|
|
|
|
|
nextAction: "",
|
|
|
|
|
|
followUpTime: toDateTimeLocalValue(),
|
|
|
|
|
|
});
|
|
|
|
|
|
setFollowUpOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleFollowUpSubmit = async () => {
|
|
|
|
|
|
if (!selectedItem || submitting) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setFollowUpError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await createOpportunityFollowUp(selectedItem.id, {
|
|
|
|
|
|
...followUpForm,
|
|
|
|
|
|
nextAction: followUpForm.nextAction || undefined,
|
|
|
|
|
|
followUpTime: new Date(followUpForm.followUpTime).toISOString(),
|
|
|
|
|
|
});
|
|
|
|
|
|
await reload();
|
|
|
|
|
|
resetFollowUpState();
|
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
} catch (submitError) {
|
|
|
|
|
|
setFollowUpError(submitError instanceof Error ? submitError.message : "新增跟进失败");
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderEmpty = () => (
|
|
|
|
|
|
<div className="rounded-2xl border border-slate-100 bg-white p-10 text-center text-sm text-slate-400 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-500">
|
|
|
|
|
|
暂无商机数据,先新增一条试试。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-19 06:23:03 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<header className="flex items-center justify-between gap-3">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">商机储备</h1>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button onClick={handleOpenCreate} className="flex items-center gap-2 rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-violet-700 active:scale-95">
|
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="relative group">
|
|
|
|
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索项目名称、客户、编码..."
|
|
|
|
|
|
value={keyword}
|
|
|
|
|
|
onChange={(event) => setKeyword(event.target.value)}
|
|
|
|
|
|
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 outline-none transition-all placeholder:text-slate-400 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white dark:placeholder:text-slate-500"
|
|
|
|
|
|
/>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{stageOptions.map((stage) => (
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<button
|
|
|
|
|
|
key={stage}
|
|
|
|
|
|
onClick={() => setFilter(stage)}
|
|
|
|
|
|
className={`whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200 ${
|
2026-03-20 08:39:07 +00:00
|
|
|
|
filter === stage
|
|
|
|
|
|
? "bg-slate-800 text-white shadow-sm dark:bg-violet-600"
|
|
|
|
|
|
: "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400 dark:hover:bg-slate-800"
|
2026-03-19 06:23:03 +00:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{stage}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{items.length > 0 ? (
|
|
|
|
|
|
items.map((opp, i) => (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: i * 0.05 }}
|
|
|
|
|
|
key={opp.id}
|
|
|
|
|
|
onClick={() => setSelectedItem(opp)}
|
|
|
|
|
|
className="group relative cursor-pointer rounded-2xl border border-slate-100 bg-white p-5 shadow-sm backdrop-blur-sm transition-all hover:border-violet-100 hover:shadow-md dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-violet-900/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
|
<div className="pr-8">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">{opp.code || `#${opp.id}`}</span>
|
|
|
|
|
|
{opp.pushedToOms ? (
|
|
|
|
|
|
<span className="rounded bg-violet-50 px-1.5 py-0.5 text-[10px] font-medium text-violet-600 dark:bg-violet-500/10 dark:text-violet-400">
|
|
|
|
|
|
已推OMS
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3 className="mt-1 line-clamp-1 text-lg font-semibold text-slate-900 dark:text-white">{opp.name || "未命名商机"}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className={`flex flex-col items-center justify-center rounded-lg border p-2 ${getConfidenceColor(opp.confidence ?? 0)}`}>
|
|
|
|
|
|
<span className="text-xs font-semibold">{opp.confidence ?? 0}%</span>
|
|
|
|
|
|
<span className="text-[10px] opacity-80">把握度</span>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-y-3 text-sm">
|
|
|
|
|
|
<div className="col-span-2 flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
<Building className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500" />
|
|
|
|
|
|
<span className="truncate">{opp.client || "未命名客户"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
<DollarSign className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500" />
|
|
|
|
|
|
<span className="font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
<Calendar className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500" />
|
|
|
|
|
|
{opp.date || "未设置"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
<Activity className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500" />
|
|
|
|
|
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-300">{opp.stage || "初步沟通"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
|
<span className="text-slate-400 dark:text-slate-500">类型:</span>
|
|
|
|
|
|
{opp.type || "新建"}
|
|
|
|
|
|
</div>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 hidden items-center justify-end border-t border-slate-50 pt-3 md:flex dark:border-slate-800/50">
|
|
|
|
|
|
<button className="flex items-center text-sm font-medium text-violet-600 dark:text-violet-400">
|
|
|
|
|
|
查看详情 <ChevronRight className="ml-1 h-4 w-4" />
|
|
|
|
|
|
</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="mt-4 flex items-center justify-end border-t border-slate-50 pt-3 md:hidden dark:border-slate-800/50">
|
|
|
|
|
|
<button className="flex items-center text-sm font-medium text-violet-600 dark:text-violet-400">
|
|
|
|
|
|
查看详情 <ChevronRight className="ml-1 h-4 w-4" />
|
|
|
|
|
|
</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
</motion.div>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : renderEmpty()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{(createOpen || editOpen) && (
|
|
|
|
|
|
<ModalShell
|
|
|
|
|
|
title={editOpen ? "编辑商机" : "新增商机"}
|
|
|
|
|
|
subtitle={editOpen ? "支持手机与电脑端修改商机资料,保存后会同步刷新详情与列表。" : "支持手机与电脑端填写,提交后会自动刷新商机列表。"}
|
|
|
|
|
|
onClose={resetCreateState}
|
|
|
|
|
|
footer={(
|
|
|
|
|
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
|
|
|
|
<button onClick={resetCreateState} className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium 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 (editOpen ? handleEditSubmit() : handleCreateSubmit())} disabled={submitting} className="rounded-xl bg-violet-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : editOpen ? "保存修改" : "确认新增"}</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">商机名称</span>
|
|
|
|
|
|
<input value={form.opportunityName} onChange={(e) => handleChange("opportunityName", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">客户名称</span>
|
|
|
|
|
|
<input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">商机金额</span>
|
|
|
|
|
|
<input type="number" min="0" value={form.amount || ""} onChange={(e) => handleChange("amount", Number(e.target.value) || 0)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计结单</span>
|
|
|
|
|
|
<input type="date" value={form.expectedCloseDate} onChange={(e) => handleChange("expectedCloseDate", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">把握度</span>
|
|
|
|
|
|
<input type="number" min="0" max="100" value={form.confidencePct} onChange={(e) => handleChange("confidencePct", Number(e.target.value) || 0)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">阶段</span>
|
|
|
|
|
|
<select value={form.stage} onChange={(e) => handleChange("stage", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50">
|
|
|
|
|
|
{stageOptions.filter((item) => item !== "全部").map((item) => (
|
|
|
|
|
|
<option key={item} value={item}>{item}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">商机类型</span>
|
|
|
|
|
|
<select value={form.opportunityType} onChange={(e) => handleChange("opportunityType", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50">
|
|
|
|
|
|
<option value="新建">新建</option>
|
|
|
|
|
|
<option value="扩容">扩容</option>
|
|
|
|
|
|
<option value="替换">替换</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">产品类别</span>
|
|
|
|
|
|
<select value={form.productType} onChange={(e) => handleChange("productType", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50">
|
|
|
|
|
|
<option value="VDI云桌面">VDI云桌面</option>
|
|
|
|
|
|
<option value="VOI云桌面">VOI云桌面</option>
|
|
|
|
|
|
<option value="IDV云桌面">IDV云桌面</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">商机来源</span>
|
|
|
|
|
|
<select value={form.source} onChange={(e) => handleChange("source", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50">
|
|
|
|
|
|
<option value="主动开发">主动开发</option>
|
|
|
|
|
|
<option value="渠道推荐">渠道推荐</option>
|
|
|
|
|
|
<option value="市场活动">市场活动</option>
|
|
|
|
|
|
<option value="老客转介绍">老客转介绍</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 sm:col-span-2 dark:border-slate-800">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">是否已推送 OMS</span>
|
|
|
|
|
|
<input type="checkbox" checked={Boolean(form.pushedToOms)} onChange={(e) => handleChange("pushedToOms", e.target.checked)} />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">备注说明</span>
|
|
|
|
|
|
<textarea rows={4} value={form.description || ""} onChange={(e) => handleChange("description", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{error ? <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">{error}</div> : null}
|
|
|
|
|
|
</ModalShell>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{followUpOpen && selectedItem && (
|
|
|
|
|
|
<ModalShell
|
|
|
|
|
|
title="新增跟进"
|
|
|
|
|
|
subtitle="商机跟进与拓展管理保持同样的填写方式,方便手机与电脑端使用。"
|
|
|
|
|
|
onClose={resetFollowUpState}
|
|
|
|
|
|
footer={(
|
|
|
|
|
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
|
|
|
|
<button onClick={resetFollowUpState} className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium 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 handleFollowUpSubmit()} disabled={submitting} className="rounded-xl bg-violet-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : "确认提交"}</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">跟进类型</span>
|
|
|
|
|
|
<select value={followUpForm.followUpType} onChange={(e) => handleFollowUpChange("followUpType", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50">
|
|
|
|
|
|
<option value="电话沟通">电话沟通</option>
|
|
|
|
|
|
<option value="拜访面谈">拜访面谈</option>
|
|
|
|
|
|
<option value="微信触达">微信触达</option>
|
|
|
|
|
|
<option value="方案沟通">方案沟通</option>
|
|
|
|
|
|
<option value="其他">其他</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">跟进时间</span>
|
|
|
|
|
|
<input type="datetime-local" value={followUpForm.followUpTime} onChange={(e) => handleFollowUpChange("followUpTime", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">跟进内容</span>
|
|
|
|
|
|
<textarea rows={5} value={followUpForm.content} onChange={(e) => handleFollowUpChange("content", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-2 sm:col-span-2">
|
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">下一步动作</span>
|
|
|
|
|
|
<input value={followUpForm.nextAction} onChange={(e) => handleFollowUpChange("nextAction", e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
|
|
|
|
|
</label>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{followUpError ? <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">{followUpError}</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-20 08:39:07 +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-md sm:rounded-none sm:rounded-l-3xl sm:border-l ${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" />
|
|
|
|
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">商机详情</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="flex-1 space-y-8 overflow-y-auto px-5 py-5 sm:px-6">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="mb-2 flex items-center gap-2">
|
|
|
|
|
|
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">{selectedItem.code || `#${selectedItem.id}`}</span>
|
|
|
|
|
|
{selectedItem.pushedToOms ? <span className="rounded bg-violet-50 px-1.5 py-0.5 text-[10px] font-medium text-violet-600 dark:bg-violet-500/10 dark:text-violet-400">已推OMS</span> : null}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<h3 className="text-xl font-bold leading-tight text-slate-900 dark:text-white">{selectedItem.name || "未命名商机"}</h3>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
<div className="mt-3 flex gap-2">
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-300">{selectedItem.stage || "初步沟通"}</span>
|
|
|
|
|
|
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence ?? 0)}`}>把握度 {selectedItem.confidence ?? 0}%</span>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
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-20 08:39:07 +00:00
|
|
|
|
<div className="grid grid-cols-2 gap-4 rounded-xl border border-slate-100 bg-slate-50/50 p-4 text-sm dark:border-slate-800 dark:bg-slate-800/20">
|
|
|
|
|
|
<div className="col-span-2"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><Building className="h-3 w-3" /> 客户名称</p><p className="font-medium text-slate-900 dark:text-white">{selectedItem.client || "无"}</p></div>
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><DollarSign className="h-3 w-3" /> 商机金额</p><p className="font-medium text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</p></div>
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><Calendar className="h-3 w-3" /> 预计结单</p><p className="font-medium text-slate-900 dark:text-white">{selectedItem.date || "无"}</p></div>
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><User className="h-3 w-3" /> 负责人</p><p className="font-medium text-slate-900 dark:text-white">{selectedItem.owner || "当前用户"}</p></div>
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><Tag className="h-3 w-3" /> 商机类型</p><p className="font-medium text-slate-900 dark:text-white">{selectedItem.type || "新建"}</p></div>
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><Activity className="h-3 w-3" /> 产品类别</p><p className="font-medium text-slate-900 dark:text-white">{selectedItem.product || "无"}</p></div>
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1"><p className="mb-1 flex items-center gap-1 text-slate-500 dark:text-slate-400"><LinkIcon className="h-3 w-3" /> 商机来源</p><p className="font-medium text-slate-900 dark:text-white">{selectedItem.source || "无"}</p></div>
|
|
|
|
|
|
<div className="col-span-2"><p className="mb-1 text-slate-500 dark:text-slate-400">备注说明</p><p className="leading-relaxed font-medium text-slate-900 dark:text-white">{selectedItem.notes || "无"}</p></div>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
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
|
|
|
|
<Clock className="h-4 w-4 text-violet-500" />
|
|
|
|
|
|
跟进记录
|
|
|
|
|
|
</h4>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button onClick={handleOpenFollowUp} className="text-xs font-medium text-violet-600 hover:text-violet-700 dark:text-violet-400">
|
|
|
|
|
|
添加记录
|
|
|
|
|
|
</button>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
{followUpRecords.length > 0 ? (
|
|
|
|
|
|
<div className="relative space-y-6 border-l-2 border-slate-100 pl-4 dark:border-slate-800">
|
|
|
|
|
|
{followUpRecords.map((record) => (
|
|
|
|
|
|
<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="mb-2 flex items-center justify-between">
|
|
|
|
|
|
<span className="rounded bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-600 dark:bg-violet-500/10 dark:text-violet-400">{record.type || "无"}</span>
|
|
|
|
|
|
<span className="text-xs text-slate-400">{record.date || "无"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">{record.content || "无"}</p>
|
|
|
|
|
|
<p className="mt-2 text-xs text-slate-400">跟进人: {record.user || "无"}</p>
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="rounded-xl border border-slate-100 bg-slate-50/50 p-6 text-center text-sm text-slate-400 dark:border-slate-800 dark:bg-slate-800/20 dark:text-slate-500">
|
|
|
|
|
|
暂无跟进记录
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-19 06:23:03 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<div className="sticky bottom-0 bg-slate-50/95 p-4 backdrop-blur sm:static dark:bg-slate-900/90">
|
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row">
|
|
|
|
|
|
<button onClick={handleOpenEdit} className="flex-1 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium 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>
|
2026-03-20 08:39:07 +00:00
|
|
|
|
<button onClick={handleOpenFollowUp} className="flex-1 rounded-xl bg-violet-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-violet-700">
|
2026-03-19 06:23:03 +00:00
|
|
|
|
写跟进
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|