forked from baron/baron-sso
사이드바 펼침/접기 형식 변환 추가
This commit is contained in:
@@ -827,7 +827,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className={shellLayoutClasses.mainMinWidth}>
|
<main className={shellLayoutClasses.mainMinWidth}>
|
||||||
<Outlet />
|
<Outlet context={isSidebarCollapsed} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "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 { PageHeader } from "../../../../../common/core/components/page";
|
||||||
import {
|
import {
|
||||||
type SortConfig,
|
type SortConfig,
|
||||||
@@ -36,6 +36,7 @@ import { Button } from "../../../components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
@@ -117,6 +118,10 @@ const tenantCSVTemplate =
|
|||||||
const tenantPageSize = 500;
|
const tenantPageSize = 500;
|
||||||
const _tenantVirtualizationThreshold = 250;
|
const _tenantVirtualizationThreshold = 250;
|
||||||
const _tenantEstimatedRowHeight = 73;
|
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 _tenantLoadAheadPx = 360;
|
||||||
const _tenantLoadAheadRows = 30;
|
const _tenantLoadAheadRows = 30;
|
||||||
|
|
||||||
@@ -139,6 +144,25 @@ function getTenantTypeLabel(type?: string) {
|
|||||||
return t(`domain.tenant_type.${type.toLowerCase()}`, type);
|
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) {
|
function getTenantTypeTextClass(type?: string) {
|
||||||
switch (type?.toUpperCase()) {
|
switch (type?.toUpperCase()) {
|
||||||
case "COMPANY_GROUP":
|
case "COMPANY_GROUP":
|
||||||
@@ -932,6 +956,17 @@ function TenantListPage() {
|
|||||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.tenants.registry.count",
|
||||||
|
"총 {{count}}개의 테넌트가 등록되어 있습니다.",
|
||||||
|
{
|
||||||
|
count: scopeTenantId
|
||||||
|
? scopedTenants.length
|
||||||
|
: allTenants.length,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -1552,10 +1587,19 @@ const TenantHierarchyView: React.FC<{
|
|||||||
isLoading,
|
isLoading,
|
||||||
}) => {
|
}) => {
|
||||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const isSidebarCollapsed = useOutletContext<boolean>() ?? false;
|
||||||
const isTest =
|
const isTest =
|
||||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||||
(typeof window !== "undefined" &&
|
(typeof window !== "undefined" &&
|
||||||
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
|
(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(
|
const { subTree } = React.useMemo(
|
||||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
|
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
|
||||||
@@ -1660,6 +1704,7 @@ const TenantHierarchyView: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isTest) return;
|
if (isTest) return;
|
||||||
@@ -1730,8 +1775,19 @@ const TenantHierarchyView: React.FC<{
|
|||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
virtualRow
|
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">
|
<TableCell className="text-center px-4">
|
||||||
@@ -1810,25 +1866,39 @@ const TenantHierarchyView: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap overflow-hidden pl-5"
|
||||||
data-testid={`tenant-internal-id-${node.id}`}
|
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">
|
<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">
|
||||||
{node.id}
|
{abbreviateUuid(node.id)}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap overflow-visible">
|
||||||
<span
|
{(() => {
|
||||||
className={cn(
|
const { primary, secondary } = splitTenantTypeLabel(
|
||||||
"text-xs font-medium uppercase tracking-[0.04em]",
|
getTenantTypeLabel(node.type),
|
||||||
getTenantTypeTextClass(node.type),
|
);
|
||||||
)}
|
return (
|
||||||
>
|
<div className="flex min-w-0 flex-col leading-tight">
|
||||||
{getTenantTypeLabel(node.type)}
|
<span
|
||||||
</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>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap overflow-hidden">
|
||||||
<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">
|
<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}
|
{node.slug}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1847,7 +1917,7 @@ const TenantHierarchyView: React.FC<{
|
|||||||
: t("ui.common.status.inactive", "비활성")}
|
: t("ui.common.status.inactive", "비활성")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap pl-3">
|
||||||
<div className="flex flex-col leading-tight">
|
<div className="flex flex-col leading-tight">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t("ui.admin.tenants.table.members_count", "{{count}}명", {
|
{t("ui.admin.tenants.table.members_count", "{{count}}명", {
|
||||||
@@ -1859,9 +1929,9 @@ const TenantHierarchyView: React.FC<{
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap text-right pl-1">
|
||||||
{node.updatedAt ? (
|
{node.updatedAt ? (
|
||||||
<div className="flex flex-col leading-tight">
|
<div className="flex flex-col items-end leading-tight">
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{new Date(node.updatedAt).toLocaleDateString("ko-KR")}
|
{new Date(node.updatedAt).toLocaleDateString("ko-KR")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1878,110 +1948,136 @@ const TenantHierarchyView: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="custom-scrollbar relative flex-1 overflow-auto"
|
className="custom-scrollbar relative flex-1 overflow-auto"
|
||||||
data-testid="tenant-table-container"
|
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">
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
<TableRow>
|
<TableRow
|
||||||
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
|
style={{
|
||||||
<Checkbox
|
display: "grid",
|
||||||
checked={
|
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||||
deletableTenants.length > 0 &&
|
minWidth: tenantTableMinWidth,
|
||||||
visibleSelectedCount === deletableTenants.length
|
}}
|
||||||
}
|
>
|
||||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
<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>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="min-w-[280px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
className={`${tenantTableHeadInteractiveClassName} min-w-[500px]`}
|
||||||
onClick={() => requestSort("name")}
|
onClick={() => requestSort("name")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className={tenantTableHeadContentClassName}>
|
||||||
{t("ui.admin.tenants.table.name", "NAME")}
|
{t("ui.admin.tenants.table.name", "NAME")}
|
||||||
{getSortIcon("name")}
|
{getSortIcon("name")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<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")}
|
onClick={() => requestSort("id")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className={tenantTableHeadContentClassName}>
|
||||||
{t("ui.admin.tenants.table.id", "ID")}
|
{t("ui.admin.tenants.table.id", "ID")}
|
||||||
{getSortIcon("id")}
|
{getSortIcon("id")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||||
onClick={() => requestSort("type")}
|
onClick={() => requestSort("type")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className={tenantTableHeadContentClassName}>
|
||||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||||
{getSortIcon("type")}
|
{getSortIcon("type")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||||
onClick={() => requestSort("slug")}
|
onClick={() => requestSort("slug")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className={tenantTableHeadContentClassName}>
|
||||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||||
{getSortIcon("slug")}
|
{getSortIcon("slug")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||||
onClick={() => requestSort("status")}
|
onClick={() => requestSort("status")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className={tenantTableHeadContentClassName}>
|
||||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||||
{getSortIcon("status")}
|
{getSortIcon("status")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
className={tenantTableHeadInteractiveClassName}
|
||||||
onClick={() => requestSort("recursiveMemberCount")}
|
onClick={() => requestSort("recursiveMemberCount")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className={tenantTableHeadContentClassName}>
|
||||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||||
{getSortIcon("recursiveMemberCount")}
|
{getSortIcon("recursiveMemberCount")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
className={tenantTableHeadInteractiveClassName}
|
||||||
onClick={() => requestSort("updatedAt")}
|
onClick={() => requestSort("updatedAt")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div
|
||||||
|
className={`${tenantTableHeadContentClassName} justify-end`}
|
||||||
|
>
|
||||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||||
{getSortIcon("updatedAt")}
|
{getSortIcon("updatedAt")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="relative">
|
<TableBody
|
||||||
{rowVirtualizer.getTotalSize() > 0 &&
|
className="relative"
|
||||||
virtualRows.length > 0 &&
|
style={
|
||||||
!(isTest && flattenedRows.length < 100) && (
|
shouldVirtualizeRows
|
||||||
<tr style={{ height: `${virtualRows[0].start}px` }}>
|
? {
|
||||||
<td colSpan={8} />
|
display: "grid",
|
||||||
</tr>
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
)}
|
minWidth: tenantTableMinWidth,
|
||||||
|
position: "relative",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{flattenedRows.length === 0 && !isLoading && (
|
{flattenedRows.length === 0 && !isLoading && (
|
||||||
<TableRow>
|
<TableRow
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||||
|
minWidth: tenantTableMinWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={8}
|
colSpan={8}
|
||||||
className="py-8 text-center text-muted-foreground"
|
className="py-8 text-center text-muted-foreground"
|
||||||
|
style={{ gridColumn: "1 / -1" }}
|
||||||
>
|
>
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTest && flattenedRows.length < 100
|
{!shouldVirtualizeRows
|
||||||
? flattenedRows.map((row, index) => renderRow(row, index))
|
? flattenedRows.map((row, index) => renderRow(row, index))
|
||||||
: virtualRows.map((virtualRow) =>
|
: virtualRows.map((virtualRow) =>
|
||||||
renderRow(
|
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 && (
|
{isFetchingNextPage && (
|
||||||
<TableRow>
|
<TableRow
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||||
|
minWidth: tenantTableMinWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableCell colSpan={8} className="py-4 text-center">
|
<TableCell colSpan={8} className="py-4 text-center">
|
||||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
|||||||
Reference in New Issue
Block a user