1
0
forked from baron/baron-sso

feat: implement bulk user actions and organization tree search with auto-expansion

This commit is contained in:
2026-03-04 15:43:00 +09:00
parent d3b4e3ef5e
commit a5102d9b25
5 changed files with 331 additions and 3 deletions

View File

@@ -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>

View File

@@ -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">