forked from baron/baron-sso
사이드바 펼침/접기 형식 변환 추가
This commit is contained in:
@@ -827,7 +827,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
<Outlet context={isSidebarCollapsed} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user