From 80bb6abb72511654ce02b9c7cb46e52953d4420d Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 7 May 2026 15:24:49 +0900 Subject: [PATCH] feat: add hierarchical view toggle to Tenant list page - Implement view switcher (List vs Hierarchy) using Tabs - Add TenantHierarchyView component with sidebar tree navigation - Display 'Same Level Organizations' and 'Sub-Organizations' for the selected node - Reuse buildTenantFullTree logic for consistent hierarchical data --- .../tenants/routes/TenantListPage.tsx | 709 +++++++++++++----- 1 file changed, 527 insertions(+), 182 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 3f877ceb..91ee729a 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -4,8 +4,15 @@ import { ArrowDown, ArrowUp, ArrowUpDown, + Building2, + ChevronDown, + ChevronRight, Download, + ExternalLink, FileSpreadsheet, + LayoutDashboard, + List, + Network, Plus, RefreshCw, Search, @@ -34,6 +41,8 @@ import { DialogTitle, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; +import { ScrollArea } from "../../../components/ui/scroll-area"; +import { Separator } from "../../../components/ui/separator"; import { Table, TableBody, @@ -42,6 +51,12 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../../components/ui/tabs"; import { type TenantSummary, deleteTenant, @@ -70,8 +85,21 @@ type SortConfig = { direction: "asc" | "desc"; }; +const getTenantIcon = (type?: string) => { + switch (type?.toUpperCase()) { + case "COMPANY_GROUP": + return Network; + case "ORGANIZATION": + case "USER_GROUP": + return Network; + default: + return Building2; + } +}; + function TenantListPage() { const navigate = useNavigate(); + const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); const [sortConfig, setSortConfig] = React.useState(null); @@ -535,15 +563,38 @@ function TenantListPage() { -
- - {t("ui.admin.tenants.registry.title", "Tenant Registry")} - - - {t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { - count: query.data?.total ?? 0, - })} - +
+
+ + {t("ui.admin.tenants.registry.title", "Tenant Registry")} + + + {t( + "msg.admin.tenants.registry.count", + "총 {{count}}개 테넌트", + { + count: query.data?.total ?? 0, + }, + )} + +
+ + setViewMode(v as "list" | "hierarchy")} + className="w-[280px]" + > + + + + {t("ui.admin.tenants.view.list", "평면 목록")} + + + + {t("ui.admin.tenants.view.hierarchy", "계층 구조")} + + +
@@ -553,185 +604,207 @@ function TenantListPage() {
)} -
-
- - - - - 0 && - deletableTenants.length > 0 && - selectedIds.length === deletableTenants.length - } - onCheckedChange={(checked) => - handleSelectAll(!!checked) - } - /> - - requestSort("id")} - > -
- {t("ui.admin.tenants.table.id", "ID")} - {getSortIcon("id")} -
-
- requestSort("name")} - > -
- {t("ui.admin.tenants.table.name", "NAME")} - {getSortIcon("name")} -
-
- requestSort("type")} - > -
- {t("ui.admin.tenants.table.type", "TYPE")} - {getSortIcon("type")} -
-
- requestSort("slug")} - > -
- {t("ui.admin.tenants.table.slug", "SLUG")} - {getSortIcon("slug")} -
-
- requestSort("status")} - > -
- {t("ui.admin.tenants.table.status", "STATUS")} - {getSortIcon("status")} -
-
- requestSort("recursiveMemberCount")} - > -
- {t("ui.admin.tenants.table.members", "MEMBERS")} - {getSortIcon("recursiveMemberCount")} -
-
- requestSort("updatedAt")} - > -
- {t("ui.admin.tenants.table.updated", "UPDATED")} - {getSortIcon("updatedAt")} -
-
-
-
- - {query.isLoading && ( - - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && tenants.length === 0 && ( - - - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} - - - )} - {tenants.map((tenant) => ( - - - {isSeedTenant(tenant) ? ( - - ) : ( + + +
+
+
+ + + 0 && + deletableTenants.length > 0 && + selectedIds.length === deletableTenants.length + } onCheckedChange={(checked) => - handleSelect(tenant, !!checked) + handleSelectAll(!!checked) } /> - )} - - - {tenant.id} - - -
- + requestSort("id")} + > +
+ {t("ui.admin.tenants.table.id", "ID")} + {getSortIcon("id")} +
+
+ requestSort("name")} + > +
+ {t("ui.admin.tenants.table.name", "NAME")} + {getSortIcon("name")} +
+
+ requestSort("type")} + > +
+ {t("ui.admin.tenants.table.type", "TYPE")} + {getSortIcon("type")} +
+
+ requestSort("slug")} + > +
+ {t("ui.admin.tenants.table.slug", "SLUG")} + {getSortIcon("slug")} +
+
+ requestSort("status")} + > +
+ {t("ui.admin.tenants.table.status", "STATUS")} + {getSortIcon("status")} +
+
+ requestSort("recursiveMemberCount")} + > +
+ {t("ui.admin.tenants.table.members", "MEMBERS")} + {getSortIcon("recursiveMemberCount")} +
+
+ requestSort("updatedAt")} + > +
+ {t("ui.admin.tenants.table.updated", "UPDATED")} + {getSortIcon("updatedAt")} +
+
+ + + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && tenants.length === 0 && ( + + - {tenant.name} - - {isSeedTenant(tenant) && ( - - {t("ui.admin.tenants.seed_badge", "초기 설정")} + {t( + "msg.admin.tenants.empty", + "아직 등록된 테넌트가 없습니다.", + )} + + + )} + {tenants.map((tenant) => ( + + + {isSeedTenant(tenant) ? ( + + ) : ( + + handleSelect(tenant, !!checked) + } + /> + )} + + + {tenant.id} + + +
+ + {tenant.name} + + {isSeedTenant(tenant) && ( + + {t( + "ui.admin.tenants.seed_badge", + "초기 설정", + )} + + )} +
+
+ + + {tenant.type} - )} -
-
- - - {tenant.type} - - - - {tenant.slug} - - - - {t( - `ui.common.status.${tenant.status}`, - tenant.status, - )} - - - - {tenant.recursiveMemberCount} - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString("ko-KR") - : "-"} - -
- ))} - -
-
-
+ + + {tenant.slug} + + + + {t( + `ui.common.status.${tenant.status}`, + tenant.status, + )} + + + + {tenant.recursiveMemberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString( + "ko-KR", + ) + : "-"} + + + ))} + + + + + + + + + +
@@ -908,4 +981,276 @@ function TenantListPage() { ); } +// --- Internal Support Components --- + +const HierarchyNode: React.FC<{ + node: TenantNode; + level: number; + selectedId: string | null; + onSelect: (id: string) => void; + isExpandedInitial?: boolean; +}> = ({ node, level, selectedId, onSelect, isExpandedInitial = false }) => { + const [isExpanded, setIsExpanded] = React.useState(isExpandedInitial); + const isSelected = selectedId === node.id; + const hasChildren = node.children && node.children.length > 0; + const TypeIcon = getTenantIcon(node.type); + + return ( +
+ + + {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +const TenantHierarchyView: React.FC<{ + tenants: TenantSummary[]; +}> = ({ tenants }) => { + const navigate = useNavigate(); + const { subTree } = React.useMemo( + () => buildTenantFullTree(tenants), + [tenants], + ); + const [selectedId, setSelectedId] = React.useState( + subTree[0]?.id || null, + ); + + const tenantMap = React.useMemo(() => { + const m = new Map(); + const fill = (nodes: TenantNode[]) => { + for (const n of nodes) { + m.set(n.id, n); + if (n.children) fill(n.children); + } + }; + fill(subTree); + return m; + }, [subTree]); + + const selectedNode = selectedId ? tenantMap.get(selectedId) : null; + + const siblings = React.useMemo(() => { + if (!selectedNode) return []; + if (!selectedNode.parentId) return subTree; + const parent = tenantMap.get(selectedNode.parentId); + return parent?.children ?? []; + }, [selectedNode, subTree, tenantMap]); + + return ( +
+ {/* Sidebar: Tree */} + + + + 조직 계층도 + + + + +
+ {subTree.map((node) => ( + + ))} +
+
+
+
+ + {/* Main: Details & Lists */} +
+ {selectedNode ? ( + <> + + +
+
+ {React.createElement(getTenantIcon(selectedNode.type), { + size: 24, + })} +
+
+ + {selectedNode.name} + + + {selectedNode.slug} ({selectedNode.type}) + +
+
+
+ +
+
+
+ +
+ {/* Siblings (Same Depth) */} + + + + 동일 레벨 조직 ({siblings.length}) + + + + +
+ {siblings.map((s) => ( +
setSelectedId(s.id)} + onKeyUp={(e) => { + if (e.key === "Enter" || e.key === " ") { + setSelectedId(s.id); + } + }} + className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors border ${ + s.id === selectedId + ? "bg-primary/5 border-primary/20" + : "hover:bg-muted border-transparent" + }`} + > +
+ {React.createElement(getTenantIcon(s.type), { + size: 14, + className: "text-muted-foreground", + })} + {s.name} +
+ + {s.recursiveMemberCount}명 + +
+ ))} +
+
+
+
+ + {/* Children */} + + + + 하위 조직 ({selectedNode.children.length}) + + + + +
+ {selectedNode.children.map((c) => ( +
setSelectedId(c.id)} + onKeyUp={(e) => { + if (e.key === "Enter" || e.key === " ") { + setSelectedId(c.id); + } + }} + className="flex items-center justify-between p-2 rounded-md hover:bg-muted cursor-pointer transition-colors border border-transparent" + > +
+ {React.createElement(getTenantIcon(c.type), { + size: 14, + className: "text-muted-foreground", + })} + {c.name} +
+ + {c.recursiveMemberCount}명 + +
+ ))} + {selectedNode.children.length === 0 && ( +
+ 하위 조직이 없습니다. +
+ )} +
+
+
+
+
+ + ) : ( +
+ 조직을 선택하세요. +
+ )} +
+
+ ); +}; + export default TenantListPage;