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