unis_crm/frontend/src/pages/OwnerTransfer.tsx

1036 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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