forked from baron/baron-sso
810 lines
28 KiB
TypeScript
810 lines
28 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 "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)}
|
|
>
|
|
<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 ? (
|
|
<button
|
|
type="button"
|
|
className="hover:bg-primary/20 rounded p-0.5"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsExpanded(!isExpanded);
|
|
}}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown size={14} />
|
|
) : (
|
|
<ChevronRight size={14} />
|
|
)}
|
|
</button>
|
|
) : (
|
|
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;
|
|
}> = ({ tenantSlug, onRefreshTrigger }) => {
|
|
const { data, isLoading, refetch } = useQuery({
|
|
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
|
|
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
|
enabled: !!tenantSlug,
|
|
});
|
|
|
|
const members = data?.items ?? [];
|
|
|
|
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>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</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>
|
|
<CardDescription className="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>
|
|
</CardDescription>
|
|
</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}
|
|
/>
|
|
</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 });
|
|
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;
|