forked from baron/baron-sso
996 lines
35 KiB
TypeScript
996 lines
35 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ArrowRight,
|
|
Briefcase,
|
|
Building2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
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,
|
|
} from "../../../components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "../../../components/ui/dropdown-menu";
|
|
import { Input } from "../../../components/ui/input";
|
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../../components/ui/table";
|
|
import { toast } from "../../../components/ui/use-toast";
|
|
import {
|
|
exportTenantsCSV,
|
|
exportUsersCSV,
|
|
fetchAllTenants,
|
|
fetchUsers,
|
|
type TenantSummary,
|
|
type UserSummary,
|
|
updateTenant,
|
|
updateUser,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
import { buildTenantFullTree, type TenantNode } 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("");
|
|
|
|
const exportChildrenMutation = useMutation({
|
|
mutationFn: (parentId: string) => exportTenantsCSV(true, parentId),
|
|
onSuccess: ({ blob, filename }) => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
onError: () =>
|
|
toast.error(
|
|
t("msg.admin.tenants.export_error", "테넌트 내보내기에 실패했습니다."),
|
|
),
|
|
});
|
|
|
|
const exportCurrentMembersMutation = useMutation({
|
|
mutationFn: (tenantSlug: string) => exportUsersCSV("", tenantSlug, false),
|
|
onSuccess: ({ blob, filename }) => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
onError: () =>
|
|
toast.error(
|
|
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
|
|
),
|
|
});
|
|
|
|
// Data Fetching
|
|
const {
|
|
data: allTenantsData,
|
|
isLoading: isTenantsLoading,
|
|
refetch: refetchTree,
|
|
} = useQuery({
|
|
queryKey: ["tenants-full-tree-v2"],
|
|
queryFn: () => fetchAllTenants(),
|
|
});
|
|
|
|
const { currentBase } = 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>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
exportCurrentMembersMutation.mutate(selectedNode.slug)
|
|
}
|
|
disabled={
|
|
!selectedNode.slug || exportCurrentMembersMutation.isPending
|
|
}
|
|
data-testid="tenant-current-users-export-btn"
|
|
>
|
|
<Download size={16} className="mr-2" />
|
|
{t("ui.admin.tenants.members.export", "선택 조직 사용자 CSV")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => exportChildrenMutation.mutate(selectedNode.id)}
|
|
disabled={exportChildrenMutation.isPending}
|
|
data-testid="tenant-subtree-export-btn"
|
|
>
|
|
<Download size={16} className="mr-2" />
|
|
{t("ui.admin.tenants.sub.export", "하위 조직 CSV")}
|
|
</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;
|