1
0
forked from baron/baron-sso

사이드바 펼침/접기 형식 변환 추가

This commit is contained in:
2026-06-05 17:47:45 +09:00
parent 729a9890a6
commit a6f9d89477
2 changed files with 160 additions and 70 deletions

View File

@@ -827,7 +827,7 @@ function AppLayout() {
</div>
</header>
<main className={shellLayoutClasses.mainMinWidth}>
<Outlet />
<Outlet context={isSidebarCollapsed} />
</main>
</div>
</div>

View File

@@ -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() {
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.registry.count",
"총 {{count}}개의 테넌트가 등록되어 있습니다.",
{
count: scopeTenantId
? scopedTenants.length
: allTenants.length,
},
)}
</CardDescription>
</div>
</div>
</CardHeader>
@@ -1552,10 +1587,19 @@ const TenantHierarchyView: React.FC<{
isLoading,
}) => {
const parentRef = React.useRef<HTMLDivElement>(null);
const isSidebarCollapsed = useOutletContext<boolean>() ?? 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,
}
}
>
<TableCell className="text-center px-4">
@@ -1810,25 +1866,39 @@ const TenantHierarchyView: React.FC<{
</div>
</TableCell>
<TableCell
className="whitespace-nowrap"
className="whitespace-nowrap overflow-hidden pl-5"
data-testid={`tenant-internal-id-${node.id}`}
>
<code className="inline-block rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{node.id}
<code className="inline-block max-w-full overflow-hidden rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground text-ellipsis">
{abbreviateUuid(node.id)}
</code>
</TableCell>
<TableCell className="whitespace-nowrap">
<span
className={cn(
"text-xs font-medium uppercase tracking-[0.04em]",
getTenantTypeTextClass(node.type),
)}
>
{getTenantTypeLabel(node.type)}
</span>
<TableCell className="whitespace-nowrap overflow-visible">
{(() => {
const { primary, secondary } = splitTenantTypeLabel(
getTenantTypeLabel(node.type),
);
return (
<div className="flex min-w-0 flex-col leading-tight">
<span
className={cn(
"block max-w-full text-xs font-medium uppercase tracking-[0.04em]",
getTenantTypeTextClass(node.type),
)}
>
{primary}
</span>
{secondary ? (
<span className="mt-0.5 block max-w-none whitespace-nowrap text-[11px] text-muted-foreground">
{secondary}
</span>
) : null}
</div>
);
})()}
</TableCell>
<TableCell className="whitespace-nowrap">
<code className="inline-flex max-w-full items-center rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
<TableCell className="whitespace-nowrap overflow-hidden">
<code className="inline-flex max-w-full items-center overflow-hidden rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground text-ellipsis">
{node.slug}
</code>
</TableCell>
@@ -1847,7 +1917,7 @@ const TenantHierarchyView: React.FC<{
: t("ui.common.status.inactive", "비활성")}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<TableCell className="whitespace-nowrap pl-3">
<div className="flex flex-col leading-tight">
<span className="font-medium">
{t("ui.admin.tenants.table.members_count", "{{count}}명", {
@@ -1859,9 +1929,9 @@ const TenantHierarchyView: React.FC<{
</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TableCell className="whitespace-nowrap text-right pl-1">
{node.updatedAt ? (
<div className="flex flex-col leading-tight">
<div className="flex flex-col items-end leading-tight">
<span className="text-xs">
{new Date(node.updatedAt).toLocaleDateString("ko-KR")}
</span>
@@ -1878,110 +1948,136 @@ const TenantHierarchyView: React.FC<{
};
return (
<div className="mt-4 flex flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex flex-1 flex-col overflow-hidden rounded-md border">
<div
ref={parentRef}
className="custom-scrollbar relative flex-1 overflow-auto"
data-testid="tenant-table-container"
>
<Table className="relative min-w-[1180px] border-separate border-spacing-0">
<Table
className="relative border-separate border-spacing-0"
style={{ display: "grid", minWidth: tenantTableMinWidth }}
>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
<Checkbox
checked={
deletableTenants.length > 0 &&
visibleSelectedCount === deletableTenants.length
}
onCheckedChange={(checked) => onSelectAll(!!checked)}
/>
<TableRow
style={{
display: "grid",
gridTemplateColumns: tenantTableGridTemplateColumns,
minWidth: tenantTableMinWidth,
}}
>
<TableHead
className={`${tenantTableHeadClassName} w-[48px] text-center`}
>
<div className="flex h-full items-center justify-center">
<Checkbox
checked={
deletableTenants.length > 0 &&
visibleSelectedCount === deletableTenants.length
}
onCheckedChange={(checked) => onSelectAll(!!checked)}
/>
</div>
</TableHead>
<TableHead
className="min-w-[280px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={`${tenantTableHeadInteractiveClassName} min-w-[500px]`}
onClick={() => requestSort("name")}
>
<div className="flex items-center">
<div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.name", "NAME")}
{getSortIcon("name")}
</div>
</TableHead>
<TableHead
className="min-w-[220px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={`${tenantTableHeadInteractiveClassName} min-w-[220px] pl-5`}
onClick={() => requestSort("id")}
>
<div className="flex items-center">
<div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={`${tenantTableHeadInteractiveClassName} pl-5`}
onClick={() => requestSort("type")}
>
<div className="flex items-center">
<div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.type", "TYPE")}
{getSortIcon("type")}
</div>
</TableHead>
<TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={`${tenantTableHeadInteractiveClassName} pl-5`}
onClick={() => requestSort("slug")}
>
<div className="flex items-center">
<div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.slug", "SLUG")}
{getSortIcon("slug")}
</div>
</TableHead>
<TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={`${tenantTableHeadInteractiveClassName} pl-5`}
onClick={() => requestSort("status")}
>
<div className="flex items-center">
<div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={tenantTableHeadInteractiveClassName}
onClick={() => requestSort("recursiveMemberCount")}
>
<div className="flex items-center">
<div className={tenantTableHeadContentClassName}>
{t("ui.admin.tenants.table.members", "MEMBERS")}
{getSortIcon("recursiveMemberCount")}
</div>
</TableHead>
<TableHead
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
className={tenantTableHeadInteractiveClassName}
onClick={() => requestSort("updatedAt")}
>
<div className="flex items-center">
<div
className={`${tenantTableHeadContentClassName} justify-end`}
>
{t("ui.admin.tenants.table.updated", "UPDATED")}
{getSortIcon("updatedAt")}
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="relative">
{rowVirtualizer.getTotalSize() > 0 &&
virtualRows.length > 0 &&
!(isTest && flattenedRows.length < 100) && (
<tr style={{ height: `${virtualRows[0].start}px` }}>
<td colSpan={8} />
</tr>
)}
<TableBody
className="relative"
style={
shouldVirtualizeRows
? {
display: "grid",
height: `${rowVirtualizer.getTotalSize()}px`,
minWidth: tenantTableMinWidth,
position: "relative",
}
: undefined
}
>
{flattenedRows.length === 0 && !isLoading && (
<TableRow>
<TableRow
style={{
display: "grid",
gridTemplateColumns: tenantTableGridTemplateColumns,
minWidth: tenantTableMinWidth,
}}
>
<TableCell
colSpan={8}
className="py-8 text-center text-muted-foreground"
style={{ gridColumn: "1 / -1" }}
>
{emptyMessage}
</TableCell>
</TableRow>
)}
{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) && (
<tr
style={{
height: `${rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end}px`,
}}
>
<td colSpan={8} />
</tr>
)}
{isFetchingNextPage && (
<TableRow>
<TableRow
style={{
display: "grid",
gridTemplateColumns: tenantTableGridTemplateColumns,
minWidth: tenantTableMinWidth,
}}
>
<TableCell colSpan={8} className="py-4 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<RefreshCw size={16} className="animate-spin" />