unis_crm/frontend/src/pages/OwnerTransfer.tsx

1036 lines
44 KiB
TypeScript
Raw Normal View History

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>
);
}