1
0
forked from baron/baron-sso

feat: enhance multi-tenant UI and fix member aggregation

- adminfront: Update TenantListPage and UserListPage sorting logic to support all columns dynamically
- adminfront: Add inline 'Move', 'Remove', and 'Delete' actions directly inside the Tenant Org Chart member table
- adminfront: Remove redundant 'ACTIONS' column and profile icon from UserListPage, making the name clickable
- adminfront: Remove 'New Member' creation link from Tenant Org Chart 'Add Member' dropdown
- backend: Fix CountByCompanyCodes query to accurately aggregate user counts using both primary company_code and company_codes array
This commit is contained in:
2026-05-07 13:50:13 +09:00
parent 5096930d68
commit c398237c35
7 changed files with 534 additions and 571 deletions

View File

@@ -195,7 +195,9 @@ const SidebarNode: React.FC<{
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
}> = ({ tenantSlug, onRefreshTrigger }) => {
allTenants?: TenantSummary[];
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
@@ -204,6 +206,54 @@ const MemberTable: React.FC<{
const members = data?.items ?? [];
const [isMoveOpen, setIsMoveOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
const [targetTenantSlug, setTargetTenantSlug] = useState("");
const [searchTenant, setSearchTenant] = useState("");
const moveMutation = useMutation({
mutationFn: (newSlug: string) => {
if (!selectedUser) throw new Error("No user selected");
return updateUser(selectedUser.id, { tenantSlug: newSlug });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t("msg.info.saved_success", "사용자 조직이 변경되었습니다."),
);
setIsMoveOpen(false);
setSelectedUser(null);
refetch();
},
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
});
const removeMutation = useMutation({
mutationFn: (userId: string) => updateUser(userId, { tenantSlug: "" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
refetch();
},
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
});
const handleMoveClick = (user: UserSummary) => {
setSelectedUser(user);
setTargetTenantSlug("");
setIsMoveOpen(true);
};
const filteredTenants = React.useMemo(() => {
if (!allTenants) return [];
if (!searchTenant) return allTenants;
return allTenants.filter(
(t) =>
t.name.toLowerCase().includes(searchTenant.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTenant.toLowerCase()),
);
}, [allTenants, searchTenant]);
if (isLoading)
return (
<div className="py-20 text-center text-muted-foreground animate-pulse">
@@ -264,6 +314,28 @@ const MemberTable: React.FC<{
{t("ui.common.detail", "상세보기")}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleMoveClick(user)}>
<ArrowRight size={14} className="mr-2" />
{t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (
window.confirm(
t(
"msg.admin.users.confirm_remove_org",
"이 조직에서 사용자를 제외하시겠습니까?",
),
)
) {
removeMutation.mutate(user.id);
}
}}
>
<FolderOpen size={14} className="mr-2" />
{t("ui.common.remove_org", "조직에서 제외")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -271,6 +343,65 @@ const MemberTable: React.FC<{
))}
</TableBody>
</Table>
<Dialog open={isMoveOpen} onOpenChange={setIsMoveOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t("ui.common.move_org", "타 조직으로 이동")}
</DialogTitle>
<DialogDescription>
{selectedUser?.name} .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={t("ui.common.search", "조직 검색...")}
value={searchTenant}
onChange={(e) => setSearchTenant(e.target.value)}
/>
<ScrollArea className="h-48 border rounded-md p-2">
<div className="space-y-1">
{filteredTenants.map((tItem) => (
<Button
key={tItem.id}
variant={
targetTenantSlug === tItem.slug ? "secondary" : "ghost"
}
className="w-full justify-start text-sm"
onClick={() => setTargetTenantSlug(tItem.slug)}
>
{React.createElement(getTenantIcon(tItem.type), {
size: 14,
className: "mr-2 opacity-70",
})}
<span>{tItem.name}</span>
<span className="ml-2 text-[10px] text-muted-foreground opacity-50">
{tItem.slug}
</span>
</Button>
))}
{filteredTenants.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
{t("msg.common.no_results", "검색 결과가 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMoveOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => moveMutation.mutate(targetTenantSlug)}
disabled={!targetTenantSlug || moveMutation.isPending}
>
{moveMutation.isPending ? "..." : t("ui.common.move", "이동")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -574,6 +705,7 @@ function TenantUserGroupsTab() {
<MemberTable
tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount}
allTenants={allTenantsData?.items ?? []}
/>
</div>
</div>
@@ -702,6 +834,7 @@ const UserAddDialog: React.FC<{
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
onOpenChange(false);
resetFields();