forked from baron/baron-sso
feat: implement bulk user actions and organization tree search with auto-expansion
This commit is contained in:
@@ -581,19 +581,42 @@ const TenantTreeRow: React.FC<{
|
||||
isRoot: boolean;
|
||||
onRemove: (id: string, name: string) => void;
|
||||
isUpdating: boolean;
|
||||
}> = ({ node, level, isRoot, onRemove, isUpdating }) => {
|
||||
searchTerm?: string;
|
||||
}> = ({ node, level, isRoot, onRemove, isUpdating, searchTerm }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
|
||||
const [isMemberListOpen, setIsMemberListOpen] = useState(false);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
// Auto expand if search matches children
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
const hasMatchingChild = (n: TenantNode): boolean => {
|
||||
return n.children.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
hasMatchingChild(c),
|
||||
);
|
||||
};
|
||||
if (hasMatchingChild(node)) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}
|
||||
}, [searchTerm, node]);
|
||||
|
||||
const isMatching =
|
||||
searchTerm &&
|
||||
(node.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
node.slug.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""}`}
|
||||
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""} ${isMatching ? "bg-primary/10 ring-1 ring-primary/20" : ""}`}
|
||||
>
|
||||
<TableCell style={{ paddingLeft: `${1 + level * 2}rem` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -750,6 +773,7 @@ const TenantTreeRow: React.FC<{
|
||||
isRoot={false}
|
||||
onRemove={onRemove}
|
||||
isUpdating={isUpdating}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -762,6 +786,7 @@ function TenantUserGroupsTab() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [treeSearchTerm, setTreeSearchTerm] = useState("");
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
@@ -1008,6 +1033,30 @@ function TenantUserGroupsTab() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.sub.tree_search_placeholder",
|
||||
"조직도 내 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={treeSearchTerm}
|
||||
onChange={(e) => setTreeSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{treeSearchTerm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTreeSearchTerm("")}
|
||||
className="text-xs"
|
||||
>
|
||||
{t("ui.common.clear_search", "검색 초기화")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/5">
|
||||
@@ -1036,6 +1085,7 @@ function TenantUserGroupsTab() {
|
||||
isRoot={true}
|
||||
onRemove={handleRemove}
|
||||
isUpdating={updateParentMutation.isPending}
|
||||
searchTerm={treeSearchTerm}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import {
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
@@ -63,6 +65,7 @@ function UserListPage() {
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<Record<string, boolean>>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -160,6 +163,50 @@ function UserListPage() {
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedUserIds.length === items.length) {
|
||||
setSelectedUserIds([]);
|
||||
} else {
|
||||
setSelectedUserIds(items.map((u) => u.id));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectUser = (id: string) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: bulkDeleteUsers,
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(t("msg.admin.users.bulk.delete_success", "선택한 사용자들이 삭제되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const bulkUpdateMutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(t("msg.admin.users.bulk.update_success", "선택한 사용자들의 정보가 수정되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const handleBulkStatusChange = (status: string) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
if (window.confirm(t("msg.admin.users.bulk.delete_confirm", "{{count}}명의 사용자를 정말 삭제하시겠습니까?", { count: selectedUserIds.length }))) {
|
||||
bulkDeleteMutation.mutate(selectedUserIds);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (userId: string, userName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
@@ -324,6 +371,14 @@ function UserListPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={items.length > 0 && selectedUserIds.length === items.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
|
||||
</TableHead>
|
||||
@@ -377,7 +432,18 @@ function UserListPage() {
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={selectedUserIds.includes(user.id) ? "bg-primary/5" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
@@ -452,6 +518,51 @@ function UserListPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedUserIds.length > 0 && (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300">
|
||||
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
|
||||
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", { count: selectedUserIds.length })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("active")}
|
||||
>
|
||||
{t("ui.common.status.active", "활성화")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("inactive")}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-background/50 hover:text-background h-8 w-8 ml-2"
|
||||
onClick={() => setSelectedUserIds([])}
|
||||
>
|
||||
<Plus size={16} className="rotate-45" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
|
||||
@@ -453,6 +453,22 @@ export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkUpdateUsers(payload: {
|
||||
userIds: string[];
|
||||
status?: string;
|
||||
role?: string;
|
||||
}) {
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkDeleteUsers(userIds: string[]) {
|
||||
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
|
||||
data: { userIds },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
const { data } = await apiClient.put<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
|
||||
Reference in New Issue
Block a user