1036 lines
44 KiB
TypeScript
1036 lines
44 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||
import { AnimatePresence, motion } from "motion/react";
|
||
import {
|
||
AlertTriangle,
|
||
ArrowLeft,
|
||
ArrowRightLeft,
|
||
BriefcaseBusiness,
|
||
Building2,
|
||
CheckCircle2,
|
||
Search,
|
||
ShieldAlert,
|
||
Users,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import ActionDialog from "@/components/ActionDialog";
|
||
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||
import {
|
||
executeOwnerTransfer,
|
||
getCurrentUser,
|
||
getStoredCurrentUserId,
|
||
listOwnerTransferTargetUsers,
|
||
previewOwnerTransfer,
|
||
type AdminUserSummary,
|
||
type OwnerTransferItem,
|
||
type OwnerTransferPayload,
|
||
type OwnerTransferPreview,
|
||
} from "@/lib/auth";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type TransferTab = "opportunities" | "sales" | "channels";
|
||
type TransferKind = "opportunity" | "sales" | "channel";
|
||
type SelectionState = {
|
||
opportunityIds: number[];
|
||
salesExpansionIds: number[];
|
||
channelExpansionIds: number[];
|
||
};
|
||
|
||
type TransferConfirmState = {
|
||
title: string;
|
||
description: string;
|
||
payload: OwnerTransferPayload;
|
||
successText: string;
|
||
confirmText: string;
|
||
sourceLabel: string;
|
||
targetLabel: string;
|
||
subjectLabel?: string;
|
||
badges: string[];
|
||
};
|
||
|
||
const TAB_META: Array<{
|
||
key: TransferTab;
|
||
label: string;
|
||
icon: typeof BriefcaseBusiness;
|
||
}> = [
|
||
{ key: "opportunities", label: "商机", icon: BriefcaseBusiness },
|
||
{ key: "sales", label: "拓展销售人员", icon: Users },
|
||
{ key: "channels", label: "拓展渠道", icon: Building2 },
|
||
];
|
||
|
||
function getActiveTenantId() {
|
||
const rawValue = localStorage.getItem("activeTenantId");
|
||
const tenantId = Number(rawValue || 0);
|
||
return Number.isFinite(tenantId) ? tenantId : 0;
|
||
}
|
||
|
||
function getDisplayUserName(user?: AdminUserSummary | null) {
|
||
if (!user) {
|
||
return "未选择";
|
||
}
|
||
return `${user.displayName || user.username} (${user.username})`;
|
||
}
|
||
|
||
function filterTransferItems(items: OwnerTransferItem[], keyword: string) {
|
||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||
if (!normalizedKeyword) {
|
||
return items;
|
||
}
|
||
return items.filter((item) => `${item.name || ""} ${item.code || ""}`.toLowerCase().includes(normalizedKeyword));
|
||
}
|
||
|
||
function countLabel(value?: number) {
|
||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||
}
|
||
|
||
function createEmptySelectionState(): SelectionState {
|
||
return {
|
||
opportunityIds: [],
|
||
salesExpansionIds: [],
|
||
channelExpansionIds: [],
|
||
};
|
||
}
|
||
|
||
function getSelectionKey(kind: TransferKind) {
|
||
if (kind === "sales") {
|
||
return "salesExpansionIds";
|
||
}
|
||
if (kind === "channel") {
|
||
return "channelExpansionIds";
|
||
}
|
||
return "opportunityIds";
|
||
}
|
||
|
||
function getTransferTabKind(tab: TransferTab): TransferKind {
|
||
if (tab === "sales") {
|
||
return "sales";
|
||
}
|
||
if (tab === "channels") {
|
||
return "channel";
|
||
}
|
||
return "opportunity";
|
||
}
|
||
|
||
export default function OwnerTransfer({
|
||
embedded = false,
|
||
onClose,
|
||
onFooterChange,
|
||
}: {
|
||
embedded?: boolean;
|
||
onClose?: () => void;
|
||
onFooterChange?: (footer: ReactNode | null) => void;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
const isMobileViewport = useIsMobileViewport();
|
||
const [users, setUsers] = useState<AdminUserSummary[]>([]);
|
||
const [currentUser, setCurrentUser] = useState<AdminUserSummary | null>(null);
|
||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||
const [previewLoading, setPreviewLoading] = useState(false);
|
||
const [executing, setExecuting] = useState(false);
|
||
const [pageError, setPageError] = useState("");
|
||
const [successMessage, setSuccessMessage] = useState("");
|
||
const [preview, setPreview] = useState<OwnerTransferPreview | null>(null);
|
||
const [fromUserId, setFromUserId] = useState(() => {
|
||
const storedUserId = getStoredCurrentUserId();
|
||
return storedUserId ? String(storedUserId) : "";
|
||
});
|
||
const [toUserId, setToUserId] = useState("");
|
||
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||
const [targetPickerKeyword, setTargetPickerKeyword] = useState("");
|
||
const [activeTab, setActiveTab] = useState<TransferTab>("opportunities");
|
||
const [searchKeyword, setSearchKeyword] = useState("");
|
||
const [selection, setSelection] = useState<SelectionState>(createEmptySelectionState);
|
||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||
const [confirmState, setConfirmState] = useState<TransferConfirmState | null>(null);
|
||
|
||
const activeTenantId = getActiveTenantId();
|
||
const tenantSelected = activeTenantId > 0;
|
||
const numericFromUserId = fromUserId ? Number(fromUserId) : undefined;
|
||
const numericToUserId = toUserId ? Number(toUserId) : undefined;
|
||
const fromUser = useMemo(
|
||
() => users.find((item) => item.userId === numericFromUserId) ?? currentUser,
|
||
[currentUser, numericFromUserId, users],
|
||
);
|
||
const toUser = useMemo(
|
||
() => users.find((item) => item.userId === numericToUserId) ?? null,
|
||
[numericToUserId, users],
|
||
);
|
||
const selectedPreviewHasSalesConflict = Boolean(preview?.salesConflicts?.length);
|
||
|
||
const loadCurrentUser = useCallback(async () => {
|
||
try {
|
||
const profile = await getCurrentUser();
|
||
const nextCurrentUser: AdminUserSummary = {
|
||
userId: profile.userId,
|
||
username: profile.username,
|
||
displayName: profile.displayName || profile.username,
|
||
status: profile.status,
|
||
tenantId: profile.tenantId,
|
||
};
|
||
setCurrentUser(nextCurrentUser);
|
||
if (profile.userId) {
|
||
setFromUserId(String(profile.userId));
|
||
}
|
||
} catch (error) {
|
||
setCurrentUser(null);
|
||
setPageError(error instanceof Error ? error.message : "获取当前登录账号失败");
|
||
}
|
||
}, []);
|
||
|
||
const loadUsers = useCallback(async () => {
|
||
if (!tenantSelected) {
|
||
setUsers([]);
|
||
return;
|
||
}
|
||
|
||
setLoadingUsers(true);
|
||
setPageError("");
|
||
try {
|
||
const response = await listOwnerTransferTargetUsers();
|
||
setUsers(response ?? []);
|
||
} catch (error) {
|
||
setUsers([]);
|
||
setPageError(error instanceof Error ? error.message : "加载用户列表失败");
|
||
} finally {
|
||
setLoadingUsers(false);
|
||
}
|
||
}, [activeTenantId, tenantSelected]);
|
||
|
||
const loadPreview = useCallback(async (sourceUserId?: number, targetUserId?: number) => {
|
||
if (!currentUser?.userId) {
|
||
setPreview(null);
|
||
return;
|
||
}
|
||
if (
|
||
!tenantSelected
|
||
|| !sourceUserId
|
||
|| !targetUserId
|
||
|| sourceUserId <= 0
|
||
|| targetUserId <= 0
|
||
|| sourceUserId === targetUserId
|
||
|| sourceUserId !== currentUser.userId
|
||
) {
|
||
setPreview(null);
|
||
return;
|
||
}
|
||
|
||
setPreviewLoading(true);
|
||
setPageError("");
|
||
try {
|
||
const response = await previewOwnerTransfer(sourceUserId, targetUserId);
|
||
setPreview(response);
|
||
} catch (error) {
|
||
setPreview(null);
|
||
setPageError(error instanceof Error ? error.message : "加载转移预检失败");
|
||
} finally {
|
||
setPreviewLoading(false);
|
||
}
|
||
}, [currentUser?.userId, tenantSelected]);
|
||
|
||
useEffect(() => {
|
||
void loadCurrentUser();
|
||
}, [loadCurrentUser]);
|
||
|
||
useEffect(() => {
|
||
void loadUsers();
|
||
}, [loadUsers]);
|
||
|
||
useEffect(() => {
|
||
void loadPreview(numericFromUserId, numericToUserId);
|
||
}, [loadPreview, numericFromUserId, numericToUserId]);
|
||
|
||
useEffect(() => {
|
||
setSearchKeyword("");
|
||
}, [activeTab, preview?.fromUserId, preview?.toUserId]);
|
||
|
||
useEffect(() => {
|
||
setSelection(createEmptySelectionState());
|
||
}, [preview?.fromUserId, preview?.toUserId]);
|
||
|
||
useEffect(() => {
|
||
if (!isMobileViewport) {
|
||
setDetailsExpanded(true);
|
||
return;
|
||
}
|
||
setDetailsExpanded(false);
|
||
}, [isMobileViewport, preview?.fromUserId, preview?.toUserId]);
|
||
|
||
const selectableUsers = useMemo(
|
||
() => users.filter((user) => String(user.userId) !== fromUserId && user.status !== 0),
|
||
[fromUserId, users],
|
||
);
|
||
const filteredSelectableUsers = useMemo(() => {
|
||
const normalizedKeyword = targetPickerKeyword.trim().toLowerCase();
|
||
if (!normalizedKeyword) {
|
||
return selectableUsers;
|
||
}
|
||
return selectableUsers.filter((user) => {
|
||
const haystack = `${user.displayName || ""} ${user.username || ""}`.toLowerCase();
|
||
return haystack.includes(normalizedKeyword);
|
||
});
|
||
}, [selectableUsers, targetPickerKeyword]);
|
||
|
||
const filteredItems = useMemo(() => {
|
||
if (!preview) {
|
||
return [];
|
||
}
|
||
if (activeTab === "sales") {
|
||
return filterTransferItems(preview.salesExpansions || [], searchKeyword);
|
||
}
|
||
if (activeTab === "channels") {
|
||
return filterTransferItems(preview.channelExpansions || [], searchKeyword);
|
||
}
|
||
return filterTransferItems(preview.opportunities || [], searchKeyword);
|
||
}, [activeTab, preview, searchKeyword]);
|
||
|
||
const selectedIdsForActiveTab = useMemo(() => {
|
||
if (activeTab === "sales") {
|
||
return selection.salesExpansionIds;
|
||
}
|
||
if (activeTab === "channels") {
|
||
return selection.channelExpansionIds;
|
||
}
|
||
return selection.opportunityIds;
|
||
}, [activeTab, selection.channelExpansionIds, selection.opportunityIds, selection.salesExpansionIds]);
|
||
|
||
const selectableFilteredItems = useMemo(() => {
|
||
if (activeTab === "sales") {
|
||
return filteredItems.filter((item) => !item.conflict);
|
||
}
|
||
return filteredItems;
|
||
}, [activeTab, filteredItems]);
|
||
|
||
const totalSelectedCount = selection.opportunityIds.length + selection.salesExpansionIds.length + selection.channelExpansionIds.length;
|
||
const activeTabSelectedCount = selectedIdsForActiveTab.length;
|
||
const selectedOpportunityCount = selection.opportunityIds.length;
|
||
const selectedSalesCount = selection.salesExpansionIds.length;
|
||
const selectedChannelCount = selection.channelExpansionIds.length;
|
||
const selectableFilteredIds = selectableFilteredItems.map((item) => item.id);
|
||
const areAllFilteredItemsSelected = selectableFilteredIds.length > 0
|
||
&& selectableFilteredIds.every((id) => selectedIdsForActiveTab.includes(id));
|
||
const activePreviewCount = activeTab === "sales"
|
||
? countLabel(preview?.salesExpansionCount)
|
||
: activeTab === "channels"
|
||
? countLabel(preview?.channelExpansionCount)
|
||
: countLabel(preview?.opportunityCount);
|
||
const primaryActionLabel = executing
|
||
? "执行中..."
|
||
: totalSelectedCount > 0
|
||
? `转移已勾选 ${totalSelectedCount} 条`
|
||
: "请选择转移数据";
|
||
const bottomActionSummary = totalSelectedCount > 0
|
||
? `已勾选 ${totalSelectedCount} 条待转移数据`
|
||
: "请先勾选需要转移的数据";
|
||
|
||
const runTransfer = useCallback(async (payload: OwnerTransferPayload, successText: string) => {
|
||
setExecuting(true);
|
||
setPageError("");
|
||
setSuccessMessage("");
|
||
try {
|
||
const result = await executeOwnerTransfer(payload);
|
||
setSuccessMessage(
|
||
`${successText} 本次共转移:商机 ${countLabel(result.transferredOpportunityCount)} 条、拓展销售人员 ${countLabel(result.transferredSalesExpansionCount)} 条、拓展渠道 ${countLabel(result.transferredChannelExpansionCount)} 条。`,
|
||
);
|
||
setSelection(createEmptySelectionState());
|
||
await loadUsers();
|
||
await loadPreview(payload.fromUserId, payload.toUserId);
|
||
} catch (error) {
|
||
setPageError(error instanceof Error ? error.message : "执行归属人转移失败");
|
||
} finally {
|
||
setExecuting(false);
|
||
}
|
||
}, [loadPreview, loadUsers]);
|
||
|
||
const confirmTransfer = useCallback(async () => {
|
||
if (!confirmState) {
|
||
return;
|
||
}
|
||
|
||
await runTransfer(confirmState.payload, confirmState.successText);
|
||
setConfirmState(null);
|
||
}, [confirmState, runTransfer]);
|
||
|
||
const handleExecute = async () => {
|
||
if (!numericFromUserId || !numericToUserId) {
|
||
setPageError("请先选择原归属人和新归属人");
|
||
return;
|
||
}
|
||
if (numericFromUserId === numericToUserId) {
|
||
setPageError("原归属人和新归属人不能相同");
|
||
return;
|
||
}
|
||
if (totalSelectedCount <= 0) {
|
||
setPageError("请先勾选需要转移的数据");
|
||
return;
|
||
}
|
||
|
||
setConfirmState({
|
||
title: "确认执行转移?",
|
||
description: "",
|
||
payload: {
|
||
fromUserId: numericFromUserId,
|
||
toUserId: numericToUserId,
|
||
transferOpportunities: selection.opportunityIds.length > 0,
|
||
transferSalesExpansions: selection.salesExpansionIds.length > 0,
|
||
transferChannelExpansions: selection.channelExpansionIds.length > 0,
|
||
selection: {
|
||
opportunityIds: selection.opportunityIds,
|
||
salesExpansionIds: selection.salesExpansionIds,
|
||
channelExpansionIds: selection.channelExpansionIds,
|
||
},
|
||
},
|
||
successText: "已勾选数据转移完成。",
|
||
confirmText: "确认转移",
|
||
sourceLabel: getDisplayUserName(fromUser),
|
||
targetLabel: getDisplayUserName(toUser),
|
||
badges: [
|
||
selectedOpportunityCount > 0 ? `商机 ${selectedOpportunityCount} 条` : "",
|
||
selectedSalesCount > 0 ? `拓展销售 ${selectedSalesCount} 条` : "",
|
||
selectedChannelCount > 0 ? `拓展渠道 ${selectedChannelCount} 条` : "",
|
||
].filter(Boolean),
|
||
});
|
||
};
|
||
|
||
const handleSingleTransfer = async (kind: TransferKind, item: OwnerTransferItem) => {
|
||
if (!numericFromUserId || !numericToUserId) {
|
||
setPageError("请先选择原归属人和新归属人");
|
||
return;
|
||
}
|
||
if (kind === "sales" && item.conflict) {
|
||
setPageError("目标归属人下已存在相同工号的拓展销售人员,请先处理冲突后再试");
|
||
return;
|
||
}
|
||
|
||
const itemTypeLabel = kind === "sales" ? "拓展销售人员" : kind === "channel" ? "拓展渠道" : "商机";
|
||
setConfirmState({
|
||
title: "确认转移当前数据?",
|
||
description: "",
|
||
payload: {
|
||
fromUserId: numericFromUserId,
|
||
toUserId: numericToUserId,
|
||
transferOpportunities: kind === "opportunity",
|
||
transferSalesExpansions: kind === "sales",
|
||
transferChannelExpansions: kind === "channel",
|
||
selection: {
|
||
opportunityIds: kind === "opportunity" ? [item.id] : [],
|
||
salesExpansionIds: kind === "sales" ? [item.id] : [],
|
||
channelExpansionIds: kind === "channel" ? [item.id] : [],
|
||
},
|
||
},
|
||
successText: "单条归属人转移已完成。",
|
||
confirmText: "确认单条转移",
|
||
sourceLabel: getDisplayUserName(fromUser),
|
||
targetLabel: getDisplayUserName(toUser),
|
||
subjectLabel: `${itemTypeLabel} · ${item.name || "未命名对象"}`,
|
||
badges: [itemTypeLabel],
|
||
});
|
||
};
|
||
|
||
const toggleItemSelection = (kind: TransferKind, itemId: number) => {
|
||
const selectionKey = getSelectionKey(kind);
|
||
setSelection((current) => {
|
||
const currentIds = current[selectionKey];
|
||
const nextIds = currentIds.includes(itemId)
|
||
? currentIds.filter((id) => id !== itemId)
|
||
: [...currentIds, itemId];
|
||
return {
|
||
...current,
|
||
[selectionKey]: nextIds,
|
||
};
|
||
});
|
||
};
|
||
|
||
const handleToggleSelectAllFiltered = () => {
|
||
const kind = getTransferTabKind(activeTab);
|
||
const selectionKey = getSelectionKey(kind);
|
||
setSelection((current) => {
|
||
const currentIds = current[selectionKey];
|
||
const nextIds = areAllFilteredItemsSelected
|
||
? currentIds.filter((id) => !selectableFilteredIds.includes(id))
|
||
: Array.from(new Set([...currentIds, ...selectableFilteredIds]));
|
||
return {
|
||
...current,
|
||
[selectionKey]: nextIds,
|
||
};
|
||
});
|
||
};
|
||
|
||
const handleClearActiveSelection = () => {
|
||
const kind = getTransferTabKind(activeTab);
|
||
const selectionKey = getSelectionKey(kind);
|
||
setSelection((current) => ({
|
||
...current,
|
||
[selectionKey]: [],
|
||
}));
|
||
};
|
||
|
||
const embeddedFooter = useMemo(() => {
|
||
if (!embedded) {
|
||
return null;
|
||
}
|
||
return (
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="min-w-0 sm:flex-1">
|
||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
|
||
{toUser ? `转移给 ${toUser.displayName || toUser.username}` : "请选择新归属人"}
|
||
</p>
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||
{bottomActionSummary}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => onClose?.()}
|
||
className="crm-btn crm-btn-secondary"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleExecute()}
|
||
disabled={!tenantSelected || executing || previewLoading || totalSelectedCount <= 0}
|
||
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{primaryActionLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}, [
|
||
bottomActionSummary,
|
||
embedded,
|
||
executing,
|
||
handleExecute,
|
||
onClose,
|
||
previewLoading,
|
||
primaryActionLabel,
|
||
tenantSelected,
|
||
toUser,
|
||
totalSelectedCount,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!embedded || !onFooterChange) {
|
||
return;
|
||
}
|
||
onFooterChange(embeddedFooter);
|
||
return () => onFooterChange(null);
|
||
}, [embedded, embeddedFooter, onFooterChange]);
|
||
|
||
return (
|
||
<div className={embedded ? "crm-section-stack" : "crm-page-stack"}>
|
||
{!embedded ? (
|
||
<header className="crm-page-header">
|
||
<div className="crm-page-heading">
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate("/profile")}
|
||
className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回我的
|
||
</button>
|
||
<h1 className="crm-page-title">项目归属转移</h1>
|
||
</div>
|
||
</header>
|
||
) : null}
|
||
|
||
{!tenantSelected ? (
|
||
<div className="crm-alert crm-alert-info">
|
||
请先切换到具体租户后再操作归属人转移。
|
||
</div>
|
||
) : null}
|
||
|
||
{pageError ? (
|
||
<div className="crm-alert crm-alert-error">{pageError}</div>
|
||
) : null}
|
||
|
||
{successMessage ? (
|
||
<div className="crm-alert crm-alert-success">{successMessage}</div>
|
||
) : null}
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="crm-card crm-card-pad-lg rounded-2xl"
|
||
>
|
||
<div className="crm-section-stack">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-base font-semibold text-slate-900 dark:text-white">转移设置</h2>
|
||
|
||
</div>
|
||
<div className={cn(
|
||
"flex items-center justify-center rounded-2xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300",
|
||
isMobileViewport ? "h-10 w-10" : "h-11 w-11",
|
||
)}>
|
||
<ArrowRightLeft className="h-5 w-5" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="crm-form-grid">
|
||
<label className="space-y-2">
|
||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新归属人</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTargetPickerOpen(true)}
|
||
disabled={!tenantSelected || loadingUsers}
|
||
className="crm-btn-sm crm-input-text flex w-full items-center justify-between border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700"
|
||
>
|
||
<span className={toUser ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
|
||
{toUser ? getDisplayUserName(toUser) : "请选择新的归属人"}
|
||
</span>
|
||
<span className="text-slate-400">⌄</span>
|
||
</button>
|
||
</label>
|
||
</div>
|
||
|
||
{selectedPreviewHasSalesConflict ? (
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-100">
|
||
存在拓展销售人员工号冲突,请先处理冲突,或仅勾选下方可转移的数据后再执行。
|
||
</div>
|
||
) : null}
|
||
|
||
</div>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.05 }}
|
||
className="crm-card crm-card-pad-lg rounded-2xl"
|
||
>
|
||
<div className="crm-section-stack">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-base font-semibold text-slate-900 dark:text-white">转移预检</h2>
|
||
<div className="flex shrink-0 items-center gap-3 text-sm">
|
||
{preview ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={handleToggleSelectAllFiltered}
|
||
disabled={selectableFilteredIds.length === 0}
|
||
className="inline-flex h-8 items-center justify-center rounded-full border border-slate-200 bg-white px-3 text-sm font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/50 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:hover:text-white"
|
||
>
|
||
{areAllFilteredItemsSelected ? "取消全选" : "全选"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleClearActiveSelection}
|
||
disabled={activeTabSelectedCount === 0}
|
||
className="inline-flex h-8 items-center justify-center rounded-full border border-slate-200 bg-white px-3 text-sm font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/50 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:hover:text-white"
|
||
>
|
||
清空
|
||
</button>
|
||
</>
|
||
) : null}
|
||
{previewLoading ? <p className="text-sm text-slate-400 dark:text-slate-500">加载中...</p> : null}
|
||
</div>
|
||
</div>
|
||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">从 {preview?.fromUserName || getDisplayUserName(fromUser)} 转移到 {preview?.toUserName || getDisplayUserName(toUser)} 的预检结果。</p>
|
||
</div>
|
||
</div>
|
||
|
||
{!preview && !previewLoading ? (
|
||
<div className="crm-empty-panel">选择原归属人和新归属人后,系统会显示当前可转移数量及冲突检查结果。</div>
|
||
) : null}
|
||
|
||
{preview ? (
|
||
<>
|
||
{isMobileViewport ? (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{TAB_META.map((tab) => {
|
||
const count = tab.key === "sales"
|
||
? countLabel(preview.salesExpansionCount)
|
||
: tab.key === "channels"
|
||
? countLabel(preview.channelExpansionCount)
|
||
: countLabel(preview.opportunityCount);
|
||
return (
|
||
<button
|
||
key={tab.key}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={cn(
|
||
"rounded-xl border px-3 py-2.5 text-left shadow-sm transition-all",
|
||
activeTab === tab.key
|
||
? "border-violet-400 bg-violet-50 shadow-[0_8px_20px_rgba(124,58,237,0.12)] ring-1 ring-violet-200 dark:border-violet-400/50 dark:bg-violet-500/15 dark:ring-violet-500/20"
|
||
: "border-slate-200 bg-slate-50/40 opacity-75 dark:border-slate-800 dark:bg-slate-800/20 dark:opacity-80",
|
||
)}
|
||
>
|
||
<p
|
||
className={cn(
|
||
"text-[11px] font-medium",
|
||
activeTab === tab.key
|
||
? "text-violet-600 dark:text-violet-300"
|
||
: "text-slate-400 dark:text-slate-500",
|
||
)}
|
||
>
|
||
{tab.key === "opportunities" ? "商机" : tab.key === "sales" ? "销售" : "渠道"}
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
"mt-1 text-xl font-bold leading-none",
|
||
activeTab === tab.key
|
||
? "text-violet-700 dark:text-violet-100"
|
||
: "text-slate-500 dark:text-slate-300",
|
||
)}
|
||
>
|
||
{count}
|
||
</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
{TAB_META.map((tab) => {
|
||
const count = tab.key === "sales"
|
||
? countLabel(preview.salesExpansionCount)
|
||
: tab.key === "channels"
|
||
? countLabel(preview.channelExpansionCount)
|
||
: countLabel(preview.opportunityCount);
|
||
return (
|
||
<button
|
||
key={tab.key}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={cn(
|
||
"rounded-2xl border p-4 text-left shadow-sm transition-all",
|
||
activeTab === tab.key
|
||
? "border-violet-400 bg-violet-50 shadow-[0_10px_24px_rgba(124,58,237,0.12)] ring-1 ring-violet-200 dark:border-violet-400/50 dark:bg-violet-500/15 dark:ring-violet-500/20"
|
||
: "border-slate-200 bg-slate-50/50 dark:border-slate-800 dark:bg-slate-800/20",
|
||
)}
|
||
>
|
||
<p
|
||
className={cn(
|
||
"text-xs font-medium uppercase tracking-[0.16em]",
|
||
activeTab === tab.key
|
||
? "text-violet-500 dark:text-violet-300"
|
||
: "text-slate-400 dark:text-slate-500",
|
||
)}
|
||
>
|
||
{tab.key === "opportunities" ? "商机数量" : tab.key === "sales" ? "拓展销售" : "拓展渠道"}
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
"mt-2 text-2xl font-bold",
|
||
activeTab === tab.key
|
||
? "text-violet-700 dark:text-violet-100"
|
||
: "text-slate-900 dark:text-white",
|
||
)}
|
||
>
|
||
{count}
|
||
</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{!isMobileViewport || detailsExpanded || isMobileViewport ? (
|
||
<>
|
||
<div className={cn("gap-2", isMobileViewport ? "space-y-2" : "grid grid-cols-[minmax(0,1fr)_auto] items-center")}>
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
value={searchKeyword}
|
||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||
placeholder="搜索名称或工号"
|
||
className={cn(
|
||
"crm-input-text w-full border border-slate-200 bg-white pl-10 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50",
|
||
isMobileViewport ? "crm-input-box" : "h-10 rounded-xl px-3 text-sm",
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
{!isMobileViewport ? <div /> : null}
|
||
</div>
|
||
|
||
{activeTab === "sales" && preview.salesConflicts.length > 0 ? (
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 p-4 dark:border-amber-500/20 dark:bg-amber-500/10">
|
||
<div className="flex items-start gap-3">
|
||
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">拓展销售人员工号冲突</p>
|
||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">目标归属人名下已存在相同工号时,系统会阻止执行“拓展销售人员”转移。</p>
|
||
<div className="mt-3 space-y-2">
|
||
{preview.salesConflicts.map((conflict) => (
|
||
<div key={`${conflict.employeeNo}-${conflict.candidateName}`} className="rounded-xl bg-white/80 px-3 py-2 text-sm text-amber-900 dark:bg-slate-900/40 dark:text-amber-100">
|
||
工号 {conflict.employeeNo || "-"} · 人员姓名 {conflict.candidateName || "-"}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="px-1 text-xs leading-5 text-slate-500 dark:text-slate-400">
|
||
当前页签已选 {activeTabSelectedCount} 条,全部已选 {totalSelectedCount} 条。
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{filteredItems.length > 0 ? filteredItems.map((item) => (
|
||
<div
|
||
key={`${activeTab}-${item.id}`}
|
||
className={cn(
|
||
"rounded-xl border px-3 py-3 transition-colors sm:px-4 sm:py-3",
|
||
selectedIdsForActiveTab.includes(item.id) && !item.conflict
|
||
? "ring-2 ring-violet-400/40"
|
||
: "",
|
||
item.conflict
|
||
? "border-amber-200 bg-amber-50/70 dark:border-amber-500/20 dark:bg-amber-500/10"
|
||
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900/50",
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIdsForActiveTab.includes(item.id)}
|
||
disabled={item.conflict}
|
||
onChange={() => toggleItemSelection(getTransferTabKind(activeTab), item.id)}
|
||
className="h-4 w-4 accent-violet-600 disabled:cursor-not-allowed"
|
||
/>
|
||
<p className="min-w-0 break-anywhere text-sm font-semibold text-slate-900 dark:text-white">{item.name || "未命名对象"}</p>
|
||
{activeTab === "sales" ? (
|
||
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">工号 {item.code || "-"}</span>
|
||
) : null}
|
||
{item.conflict ? (
|
||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||
<AlertTriangle className="h-3.5 w-3.5" />
|
||
有冲突
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
|
||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||
可转移
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleSingleTransfer(
|
||
activeTab === "sales" ? "sales" : activeTab === "channels" ? "channel" : "opportunity",
|
||
item,
|
||
)}
|
||
disabled={executing || item.conflict}
|
||
title="单条转移"
|
||
aria-label="单条转移"
|
||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-colors hover:border-violet-300 hover:text-violet-700 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900/50 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-300"
|
||
>
|
||
<ArrowRightLeft className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)) : (
|
||
<div className="crm-empty-panel">暂无数据</div>
|
||
)}
|
||
</div>
|
||
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</motion.div>
|
||
|
||
{targetPickerOpen ? (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-[140] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
|
||
onClick={() => setTargetPickerOpen(false)}
|
||
/>
|
||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
|
||
<div className="w-full max-w-xl rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
|
||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||
<div>
|
||
<p className="text-base font-semibold text-slate-900 dark:text-white">选择新归属人</p>
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">搜索并选择接收归属数据的账号</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTargetPickerOpen(false)}
|
||
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="space-y-3 px-5 py-4">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
value={targetPickerKeyword}
|
||
onChange={(event) => setTargetPickerKeyword(event.target.value)}
|
||
placeholder="搜索姓名或账号"
|
||
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 pl-10 pr-24 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||
/>
|
||
{targetPickerKeyword ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setTargetPickerKeyword("")}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full px-2 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||
>
|
||
清空搜索
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="max-h-[min(60vh,28rem)] space-y-2 overflow-y-auto pr-1">
|
||
{filteredSelectableUsers.length > 0 ? filteredSelectableUsers.map((user) => {
|
||
const selected = String(user.userId) === toUserId;
|
||
return (
|
||
<button
|
||
key={user.userId}
|
||
type="button"
|
||
onClick={() => {
|
||
setToUserId(String(user.userId));
|
||
setTargetPickerOpen(false);
|
||
}}
|
||
className={cn(
|
||
"flex w-full items-center justify-between rounded-2xl px-4 py-3 text-left text-sm transition-colors",
|
||
selected
|
||
? "bg-violet-600 text-white shadow-sm"
|
||
: "border border-transparent text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800",
|
||
)}
|
||
>
|
||
<span className="break-anywhere">{getDisplayUserName(user)}</span>
|
||
{selected ? <CheckCircle2 className="h-4 w-4 shrink-0" /> : null}
|
||
</button>
|
||
);
|
||
}) : (
|
||
<div className="rounded-2xl border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||
未找到匹配的归属人
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
<AnimatePresence>
|
||
{confirmState ? (
|
||
<ActionDialog
|
||
title={confirmState.title}
|
||
description={confirmState.description}
|
||
tone="violet"
|
||
icon={<ArrowRightLeft className="h-6 w-6" />}
|
||
confirmText={confirmState.confirmText}
|
||
loading={executing}
|
||
onClose={() => setConfirmState(null)}
|
||
onConfirm={confirmTransfer}
|
||
>
|
||
<div className="space-y-3">
|
||
{confirmState.subjectLabel ? (
|
||
<div className="rounded-2xl border border-slate-200/80 bg-white/90 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">
|
||
转移对象
|
||
</p>
|
||
<p className="mt-1 break-anywhere text-sm font-semibold text-slate-900 dark:text-white">
|
||
{confirmState.subjectLabel}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center">
|
||
<div className="rounded-2xl border border-slate-200/80 bg-white/90 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||
<p className="text-xs text-slate-400 dark:text-slate-500">原归属人</p>
|
||
<p className="mt-1 break-anywhere text-sm font-semibold text-slate-900 dark:text-white">
|
||
{confirmState.sourceLabel}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex justify-center py-1 text-violet-500 dark:text-violet-300">
|
||
<ArrowRightLeft className="h-4 w-4" />
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-violet-200/80 bg-violet-50/80 px-4 py-3 dark:border-violet-500/20 dark:bg-violet-500/10">
|
||
<p className="text-xs text-violet-600 dark:text-violet-300">新归属人</p>
|
||
<p className="mt-1 break-anywhere text-sm font-semibold text-slate-900 dark:text-white">
|
||
{confirmState.targetLabel}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{confirmState.badges.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{confirmState.badges.map((badge) => (
|
||
<span
|
||
key={badge}
|
||
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
|
||
>
|
||
{badge}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="text-xs leading-5 text-slate-500 dark:text-slate-400">
|
||
确认后将立即生效,请确保选择无误。
|
||
</div>
|
||
</div>
|
||
</ActionDialog>
|
||
) : null}
|
||
</AnimatePresence>
|
||
|
||
{!embedded && !isMobileViewport ? (
|
||
<div className="w-full shrink-0 border-t border-slate-200/80 bg-slate-50/95 backdrop-blur dark:border-slate-800/80 dark:bg-slate-900/90">
|
||
<div className="px-4 py-4 sm:px-0">
|
||
<div className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
|
||
{toUser ? `转移给 ${toUser.displayName || toUser.username}` : "请选择新归属人"}
|
||
</p>
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||
{bottomActionSummary}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleExecute()}
|
||
disabled={!tenantSelected || executing || previewLoading || totalSelectedCount <= 0}
|
||
className="crm-btn crm-btn-primary shrink-0 px-5 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{primaryActionLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{!embedded && isMobileViewport ? <div className="h-28" aria-hidden="true" /> : null}
|
||
|
||
{!embedded && isMobileViewport ? (
|
||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 px-4 pb-[calc(0.9rem+env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur dark:border-slate-800 dark:bg-slate-950/92">
|
||
<div className="mx-auto flex max-w-screen-sm items-center gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
|
||
{toUser ? `转移给 ${toUser.displayName || toUser.username}` : "请选择新归属人"}
|
||
</p>
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||
{bottomActionSummary}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleExecute()}
|
||
disabled={!tenantSelected || executing || previewLoading || totalSelectedCount <= 0}
|
||
className="crm-btn crm-btn-primary shrink-0 px-5 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{primaryActionLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|