diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 6ba74cb6..929a0846 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -827,7 +827,7 @@ function AppLayout() {
- +
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d49d7443..8f9900fe 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -20,7 +20,7 @@ import { Upload, } from "lucide-react"; import * as React from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useOutletContext } from "react-router-dom"; import { PageHeader } from "../../../../../common/core/components/page"; import { type SortConfig, @@ -36,6 +36,7 @@ import { Button } from "../../../components/ui/button"; import { Card, CardContent, + CardDescription, CardHeader, CardTitle, } from "../../../components/ui/card"; @@ -117,6 +118,10 @@ const tenantCSVTemplate = const tenantPageSize = 500; const _tenantVirtualizationThreshold = 250; const _tenantEstimatedRowHeight = 73; +const tenantTableHeadClassName = + "h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap"; +const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`; +const tenantTableHeadContentClassName = "flex h-full items-center gap-1"; const _tenantLoadAheadPx = 360; const _tenantLoadAheadRows = 30; @@ -139,6 +144,25 @@ function getTenantTypeLabel(type?: string) { return t(`domain.tenant_type.${type.toLowerCase()}`, type); } +function splitTenantTypeLabel(label: string) { + const match = label.match(/^(.*?)\s*(\(.+\))$/); + if (!match) { + return { primary: label, secondary: null as string | null }; + } + return { + primary: match[1].trim(), + secondary: match[2].trim(), + }; +} + +function abbreviateUuid(value: string) { + const parts = value.split("-"); + if (parts.length < 4) { + return value; + } + return `${parts.slice(0, 4).join("-")}-...`; +} + function getTenantTypeTextClass(type?: string) { switch (type?.toUpperCase()) { case "COMPANY_GROUP": @@ -932,6 +956,17 @@ function TenantListPage() { {t("ui.admin.tenants.registry.title", "Tenant Registry")} + + {t( + "msg.admin.tenants.registry.count", + "총 {{count}}개의 테넌트가 등록되어 있습니다.", + { + count: scopeTenantId + ? scopedTenants.length + : allTenants.length, + }, + )} + @@ -1552,10 +1587,19 @@ const TenantHierarchyView: React.FC<{ isLoading, }) => { const parentRef = React.useRef(null); + const isSidebarCollapsed = useOutletContext() ?? false; const isTest = (typeof process !== "undefined" && process.env.NODE_ENV === "test") || (typeof window !== "undefined" && (window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE); + const tenantTableGridTemplateColumns = React.useMemo( + () => + isSidebarCollapsed + ? "48px minmax(380px, 1fr) 310px 140px 240px 120px 120px 110px" + : "48px minmax(500px, 1fr) 240px 130px 226px 100px 100px 110px", + [isSidebarCollapsed], + ); + const tenantTableMinWidth = "100%"; const { subTree } = React.useMemo( () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search), @@ -1660,6 +1704,7 @@ const TenantHierarchyView: React.FC<{ }); const virtualRows = rowVirtualizer.getVirtualItems(); + const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100); React.useEffect(() => { if (isTest) return; @@ -1730,8 +1775,19 @@ const TenantHierarchyView: React.FC<{ )} style={ virtualRow - ? { transform: `translateY(${virtualRow.start}px)` } - : undefined + ? { + display: "grid", + gridTemplateColumns: tenantTableGridTemplateColumns, + minWidth: tenantTableMinWidth, + position: "absolute", + transform: `translateY(${virtualRow.start}px)`, + width: "100%", + } + : { + display: "grid", + gridTemplateColumns: tenantTableGridTemplateColumns, + minWidth: tenantTableMinWidth, + } } > @@ -1810,25 +1866,39 @@ const TenantHierarchyView: React.FC<{ - - {node.id} + + {abbreviateUuid(node.id)} - - - {getTenantTypeLabel(node.type)} - + + {(() => { + const { primary, secondary } = splitTenantTypeLabel( + getTenantTypeLabel(node.type), + ); + return ( +
+ + {primary} + + {secondary ? ( + + {secondary} + + ) : null} +
+ ); + })()}
- - + + {node.slug} @@ -1847,7 +1917,7 @@ const TenantHierarchyView: React.FC<{ : t("ui.common.status.inactive", "비활성")} - +
{t("ui.admin.tenants.table.members_count", "{{count}}명", { @@ -1859,9 +1929,9 @@ const TenantHierarchyView: React.FC<{
- + {node.updatedAt ? ( -
+
{new Date(node.updatedAt).toLocaleDateString("ko-KR")} @@ -1878,110 +1948,136 @@ const TenantHierarchyView: React.FC<{ }; return ( -
+
- +
- - - 0 && - visibleSelectedCount === deletableTenants.length - } - onCheckedChange={(checked) => onSelectAll(!!checked)} - /> + + +
+ 0 && + visibleSelectedCount === deletableTenants.length + } + onCheckedChange={(checked) => onSelectAll(!!checked)} + /> +
requestSort("name")} > -
+
{t("ui.admin.tenants.table.name", "NAME")} {getSortIcon("name")}
requestSort("id")} > -
+
{t("ui.admin.tenants.table.id", "ID")} {getSortIcon("id")}
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")}
- - {rowVirtualizer.getTotalSize() > 0 && - virtualRows.length > 0 && - !(isTest && flattenedRows.length < 100) && ( -
- - )} - + {flattenedRows.length === 0 && !isLoading && ( - + {emptyMessage} )} - {isTest && flattenedRows.length < 100 + {!shouldVirtualizeRows ? flattenedRows.map((row, index) => renderRow(row, index)) : virtualRows.map((virtualRow) => renderRow( @@ -1991,20 +2087,14 @@ const TenantHierarchyView: React.FC<{ ), )} - {rowVirtualizer.getTotalSize() > 0 && - virtualRows.length > 0 && - !(isTest && flattenedRows.length < 100) && ( - - - )} - {isFetchingNextPage && ( - +
-
-