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> </div>
</header> </header>
<main className={shellLayoutClasses.mainMinWidth}> <main className={shellLayoutClasses.mainMinWidth}>
<Outlet /> <Outlet context={isSidebarCollapsed} />
</main> </main>
</div> </div>
</div> </div>

View File

@@ -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" />