1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
chan f6cf261fd5 fix: resolve tenant member removal and move aggregation bugs
- adminfront: Update removeMutation to correctly pass 'isRemoveTenant: true' and the specific tenant slug instead of empty string
- backend: Fix 'Move' operation (Normal Update) in UpdateUser to correctly remove the old primary company code from the 'companyCodes' array and sync the deletion to Keto, ensuring accurate member count aggregation
2026-05-07 15:43:08 +09:00

941 lines
33 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRight,
Briefcase,
Building2,
ChevronDown,
ChevronRight,
CornerDownRight,
ExternalLink,
FolderOpen,
LayoutDashboard,
MoreHorizontal,
Network,
Plus,
RefreshCw,
Search,
Settings,
Trash2,
UserCircle,
UserPlus,
Users,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantSummary,
type UserSummary,
createUser,
fetchTenants,
fetchUsers,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
case "COMPANY_GROUP":
return Briefcase;
case "PERSONAL":
return UserCircle;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:
return Building2;
}
};
// --- Components ---
const SidebarNode: React.FC<{
node: TenantNode;
level: number;
selectedId: string;
onSelect: (id: string) => void;
searchTerm: string;
}> = ({ node, level, selectedId, onSelect, searchTerm }) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedId === node.id;
const TypeIcon = getTenantIcon(node.type);
// Auto-expand on search
React.useEffect(() => {
if (searchTerm) {
const matchInDescendants = (n: TenantNode): boolean => {
return n.children.some(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
matchInDescendants(c),
);
};
if (matchInDescendants(node)) setIsExpanded(true);
}
}, [searchTerm, node]);
const isMatching =
searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase());
return (
<div className="flex flex-col">
<button
type="button"
className={`w-full text-left flex items-center group px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
isSelected
? "bg-primary text-primary-foreground font-semibold"
: "hover:bg-muted/60 text-muted-foreground hover:text-foreground"
} ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`}
onClick={() => {
onSelect(node.id);
if (hasChildren) setIsExpanded(!isExpanded);
}}
>
<div className="flex items-center flex-1 min-w-0">
{/* Indent & Expander */}
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
<div className="w-5 h-5 flex items-center justify-center mr-1">
{hasChildren ? (
<span className="rounded p-0.5">
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</span>
) : (
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
)}
</div>
<TypeIcon
size={16}
className={`shrink-0 mr-2 ${isSelected ? "text-primary-foreground" : "text-primary/70"}`}
/>
<span className="text-sm truncate">{node.name}</span>
</div>
<Badge
variant={isSelected ? "secondary" : "outline"}
className={`ml-2 text-[10px] px-1 h-4 min-w-[1.5rem] justify-center ${
isSelected ? "" : "opacity-60"
}`}
>
{node.recursiveMemberCount}
</Badge>
</button>
{isExpanded && hasChildren && (
<div className="flex flex-col">
{node.children.map((child) => (
<SidebarNode
key={child.id}
node={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
searchTerm={searchTerm}
/>
))}
</div>
)}
</div>
);
};
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
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),
enabled: !!tenantSlug,
});
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, isRemoveTenant: true }),
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">
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
</div>
);
if (members.length === 0)
return (
<div className="py-20 flex flex-col items-center justify-center text-muted-foreground opacity-50 border-2 border-dashed rounded-lg">
<Users size={48} className="mb-4" />
<p>
{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}
</p>
</div>
);
return (
<div className="border rounded-md overflow-hidden bg-[var(--color-panel)]">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead>{t("ui.admin.users.table.name", "이름")}</TableHead>
<TableHead>{t("ui.admin.users.table.email", "이메일")}</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.table.role", "역할")}
</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((user) => (
<TableRow
key={user.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-xs font-mono">{user.email}</TableCell>
<TableCell className="text-right">
<Badge
variant="secondary"
className="text-[10px] font-bold uppercase"
>
{user.role}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<ExternalLink size={14} className="mr-2" />
{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>
</TableRow>
))}
</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>
);
};
// --- Main Component ---
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
const [treeSearch, setTreeSearch] = useState("");
const [refreshMembersCount, setRefreshMembersCount] = useState(0);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
// Data Fetching
const {
data: allTenantsData,
isLoading: isTenantsLoading,
refetch: refetchTree,
} = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(1000, 0),
});
const { currentBase, subTree } = useMemo(() => {
const allItems = allTenantsData?.items ?? [];
return buildTenantFullTree(allItems, tenantId);
}, [allTenantsData, tenantId]);
const selectedNode = useMemo(() => {
// Find selected node in the built tree
const findNode = (nodes: TenantNode[], id: string): TenantNode | null => {
if (!currentBase) return null;
if (currentBase.id === id) return currentBase;
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
if (!currentBase) return null;
return findNode(currentBase.children, selectedNodeId) || currentBase;
}, [currentBase, selectedNodeId]);
// Mutations
const updateParentMutation = useMutation({
mutationFn: ({
id,
parentId,
}: { id: string; parentId: string | undefined }) =>
updateTenant(id, { parentId: parentId || "" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t("msg.info.saved_success", "조직 구조가 업데이트되었습니다."),
);
setIsAddExistingOpen(false);
},
});
const handleRemoveNode = (id: string, name: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.remove_sub_confirm",
`${name} 조직을 하위에서 제외할까요?`,
{ name },
),
)
) {
updateParentMutation.mutate({ id, parentId: undefined });
if (selectedNodeId === id) setSelectedNodeId(tenantId || "");
}
};
if (isTenantsLoading)
return (
<div className="p-12 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "조직 정보를 불러오는 중...")}
</div>
);
if (!currentBase)
return (
<div className="p-12 text-center text-muted-foreground">
.
</div>
);
const candidates = (allTenantsData?.items ?? []).filter(
(t) =>
t.id !== tenantId &&
t.parentId !== tenantId &&
(existingSearch === "" ||
t.name.toLowerCase().includes(existingSearch.toLowerCase()) ||
t.slug.toLowerCase().includes(existingSearch.toLowerCase())),
);
return (
<div className="flex h-[calc(100vh-theme(spacing.32))] gap-6 mt-6 overflow-hidden">
{/* --- Left Panel: Sidebar Tree --- */}
<Card className="w-80 flex flex-col shrink-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-primary" />
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetchTree()}
>
<RefreshCw size={14} />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "조직 검색...")}
className="pl-8 h-8 text-xs bg-muted/40"
value={treeSearch}
onChange={(e) => setTreeSearch(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-2">
<ScrollArea className="h-full pr-2">
<SidebarNode
node={currentBase}
level={0}
selectedId={selectedNodeId}
onSelect={setSelectedNodeId}
searchTerm={treeSearch}
/>
</ScrollArea>
</CardContent>
<div className="p-3 border-t bg-muted/5">
<Button
variant="outline"
size="sm"
className="w-full text-xs h-8"
onClick={() => setIsAddExistingOpen(true)}
>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</Button>
</div>
</Card>
{/* --- Right Panel: Selected Node Content --- */}
<div className="flex-1 flex flex-col min-w-0">
{selectedNode ? (
<Card className="flex-1 flex flex-col border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="border-b bg-muted/5 py-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
{React.createElement(getTenantIcon(selectedNode.type), {
size: 24,
})}
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">
{selectedNode.name}
</CardTitle>
<Badge
variant="outline"
className="text-[10px] font-mono opacity-60"
>
{selectedNode.slug}
</Badge>
</div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-1">
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</span>
<span className="opacity-30">|</span>
<Badge variant="secondary" className="text-[10px] h-4">
{t(
`domain.tenant_type.${selectedNode.type.toLowerCase()}`,
selectedNode.type,
)}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsUserAddOpen(true)}
>
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t("ui.common.manage", "조직 관리")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/tenants/${selectedNode.id}`}>
<LayoutDashboard size={14} className="mr-2" />
</Link>
</DropdownMenuItem>
{!selectedNode.parentId && (
<DropdownMenuItem asChild>
<Link to={`/tenants/new?parentId=${selectedNode.id}`}>
<Plus size={14} className="mr-2" />
{t("ui.admin.tenants.sub.add", "하위 부서 생성")}
</Link>
</DropdownMenuItem>
)}
{selectedNode.id !== tenantId && (
<DropdownMenuItem
className="text-destructive"
onClick={() =>
handleRemoveNode(selectedNode.id, selectedNode.name)
}
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.remove", "조직 계층에서 제외")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-6 space-y-8">
{selectedNode.children.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-muted-foreground" />
({selectedNode.children.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{selectedNode.children?.map((child) => (
<Card
key={child.id}
className="cursor-pointer hover:border-primary/50 transition-colors bg-muted/20"
onClick={() => setSelectedNodeId(child.id)}
>
<CardHeader className="p-4 space-y-1">
<div className="flex items-center justify-between">
<div className="p-1.5 rounded bg-background">
{React.createElement(
getTenantIcon(child.type),
{
size: 14,
className: "text-primary",
},
)}
</div>
<Badge variant="outline" className="text-[9px]">
{child.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</Badge>
</div>
<CardTitle className="text-sm">
{child.name}
</CardTitle>
<CardDescription className="text-[10px] truncate">
{child.slug}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Users size={16} className="text-muted-foreground" />
{t("ui.admin.tenants.members.list_title", "소속 멤버")}
</h3>
<MemberTable
tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount}
allTenants={allTenantsData?.items ?? []}
/>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground opacity-30 border-2 border-dashed rounded-lg">
<div className="text-center">
<FolderOpen size={64} className="mx-auto mb-4" />
<p> .</p>
</div>
</div>
)}
</div>
{/* --- Dialogs --- */}
<UserAddDialog
tenantSlug={selectedNode?.slug || ""}
tenantName={selectedNode?.name || ""}
open={isUserAddOpen}
onOpenChange={(open) => {
setIsUserAddOpen(open);
if (!open) setRefreshMembersCount((prev) => prev + 1);
}}
/>
<Dialog open={isAddExistingOpen} onOpenChange={setIsAddExistingOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</DialogTitle>
<DialogDescription>
[{currentBase.name}] .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-9"
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{candidates?.map((tenantItem) => (
<TableRow
key={tenantItem.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() =>
updateParentMutation.mutate({
id: tenantItem.id,
parentId: tenantId,
})
}
>
<TableCell>
<div className="flex items-center gap-2">
{React.createElement(getTenantIcon(tenantItem.type), {
size: 14,
className: "text-muted-foreground",
})}
<div>
<p className="text-sm font-medium">
{tenantItem.name}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
{tenantItem.slug}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="h-7 px-2">
<Plus size={14} className="mr-1" />{" "}
{t("ui.common.add", "추가")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Internal Support Components ---
const UserAddDialog: React.FC<{
tenantSlug: string;
tenantName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ tenantSlug, tenantName, open, onOpenChange }) => {
const queryClient = useQueryClient();
const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSearch = async () => {
if (!userSearch) return;
setIsSearching(true);
try {
const res = await fetchUsers(20, 0, userSearch);
setSearchResults(res.items);
} catch (err) {
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
} finally {
setIsSearching(false);
}
};
const handleAssign = async () => {
if (!selectedUserId) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
onOpenChange(false);
resetFields();
} catch (err) {
const error = err as AxiosError<{ error?: string }>;
toast.error(
error.response?.data?.error ||
t("msg.admin.users.detail.update_error", "배정 실패"),
);
} finally {
setIsSubmitting(false);
}
};
const resetFields = () => {
setUserSearch("");
setSearchResults([]);
setSelectedUserId(null);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) resetFields();
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")}
</DialogTitle>
<DialogDescription>
[{tenantName}] .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedUserId(user.id)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{selectedUserId === user.id && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
>
{t("ui.common.add", "배정")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default TenantUserGroupsTab;