1
0
forked from baron/baron-sso

adminfront 사용자/테넌트 테이블 쉘 공통화

This commit is contained in:
2026-05-13 16:51:26 +09:00
parent 40d64acf15
commit c8ac953b14
2 changed files with 125 additions and 242 deletions

View File

@@ -1,5 +1,4 @@
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Building2,
@@ -25,10 +24,14 @@ import {
sortableTableHeaderClassName,
} from "../../../../../common/core/components/sort";
import {
type SortConfig,
type SortResolverMap,
commonTableShellClass,
commonTableViewportClass,
} from "../../../../../common/ui/table";
import {
sortItems,
toggleSort,
type SortConfig,
type SortResolverMap,
} from "../../../../../common/core/utils";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
@@ -76,12 +79,7 @@ import {
importTenantsCSV,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import {
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
} from "../../users/orgChartPicker";
import { isSeedTenant } from "../utils/protectedTenants";
import {
type TenantImportPreviewRow,
@@ -95,14 +93,8 @@ import {
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
const tenantPageSize = 500;
const tenantVirtualizationThreshold = 250;
const tenantEstimatedRowHeight = 73;
const tenantLoadAheadPx = 360;
const tenantLoadAheadRows = 30;
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
type TenantListRow = TenantSummary & { recursiveMemberCount: number };
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
@@ -263,17 +255,15 @@ function TenantListPage() {
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
React.useEffect(() => {
if (profile && profileRole === "tenant_admin") {
if (profile?.role === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0;
if (
(manageableCount === 1 || manageableCount === 0) &&
@@ -282,24 +272,15 @@ function TenantListPage() {
navigate(`/tenants/${profile.tenantId}`, { replace: true });
}
}
}, [profile, profileRole, navigate]);
}, [profile, navigate]);
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy"],
queryFn: ({ pageParam }) =>
fetchTenants(
tenantPageSize,
0,
undefined,
pageParam ? pageParam : undefined,
),
initialPageParam: "",
getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined,
const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0),
enabled:
profileRole === "super_admin" ||
(profileRole === "tenant_admin" &&
(profile?.manageableTenants?.length ?? 0) > 1),
profile?.role === "super_admin" ||
(profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) > 1),
});
const deleteMutation = useMutation({
@@ -364,8 +345,8 @@ function TenantListPage() {
if (
profile &&
profileRole !== "super_admin" &&
profileRole !== "tenant_admin"
profile.role !== "super_admin" &&
profile.role !== "tenant_admin"
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
@@ -380,8 +361,7 @@ function TenantListPage() {
}
if (
profile &&
profileRole === "tenant_admin" &&
profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1
) {
return null;
@@ -394,28 +374,7 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const tenantPages = query.data?.pages ?? [];
const rawTenants = tenantPages.flatMap((page) => page.items);
const tenantTotal = tenantPages[0]?.total ?? 0;
const hanmacFamilyTenantId = React.useMemo(() => {
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
if (typeof envTenantId === "string" && envTenantId.trim()) {
return envTenantId.trim();
}
return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id;
}, [rawTenants]);
const allTenants = React.useMemo(() => {
if (profileRole === "super_admin") {
return rawTenants;
}
if (
profile &&
isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId)
) {
return rawTenants;
}
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
}, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
const allTenants = query.data?.items ?? [];
const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants);
const tenantSortResolvers = React.useMemo<
@@ -465,56 +424,6 @@ function TenantListPage() {
return sortItems(enriched, sortConfig, tenantSortResolvers);
}, [allTenants, search, sortConfig, tenantSortResolvers]);
const shouldVirtualizeTenants =
tenants.length >= tenantVirtualizationThreshold;
const tenantRowVirtualizer = useVirtualizer({
count: tenants.length,
getScrollElement: () => tenantTableScrollRef.current,
estimateSize: () => tenantEstimatedRowHeight,
overscan: 12,
enabled: shouldVirtualizeTenants,
});
const virtualTenantRows = shouldVirtualizeTenants
? tenantRowVirtualizer.getVirtualItems()
: [];
const lastVirtualTenantIndex =
virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1;
const fetchNextTenantPage = React.useCallback(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage();
}
}, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]);
const handleTenantTableScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const scrollElement = event.currentTarget;
const distanceToEnd =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
if (distanceToEnd <= tenantLoadAheadPx) {
fetchNextTenantPage();
}
},
[fetchNextTenantPage],
);
React.useEffect(() => {
if (
!shouldVirtualizeTenants ||
lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows
) {
return;
}
fetchNextTenantPage();
}, [
fetchNextTenantPage,
lastVirtualTenantIndex,
shouldVirtualizeTenants,
tenants.length,
]);
const requestSort = (key: TenantSortKey) => {
setSortConfig((current) => toggleSort(current, key));
};
@@ -690,96 +599,6 @@ function TenantListPage() {
deleteMutation.mutate(tenantId);
};
const renderTenantRow = (
tenant: TenantListRow,
options?: {
style?: React.CSSProperties;
virtualIndex?: number;
},
) => (
<TableRow
key={tenant.id}
data-index={options?.virtualIndex}
ref={
options?.virtualIndex === undefined
? undefined
: tenantRowVirtualizer.measureElement
}
style={options?.style}
>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) => handleSelect(tenant, !!checked)}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`tenant-internal-id-${tenant.id}`}
>
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/tenants/${tenant.id}`}
className="hover:underline text-primary cursor-pointer"
>
{tenant.name}
</Link>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t("ui.admin.tenants.seed_badge", "초기 설정")}
</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline" className="text-[10px] font-mono">
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{tenant.slug}</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.recursiveMemberCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={isSeedTenant(tenant) || deleteMutation.isPending}
onClick={() => handleDelete(tenant.id, tenant.name)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
);
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
@@ -908,7 +727,7 @@ function TenantListPage() {
"msg.admin.tenants.registry.count",
"총 {{count}}개 테넌트",
{
count: tenantTotal,
count: query.data?.total ?? 0,
},
)}
</CardDescription>
@@ -950,13 +769,8 @@ function TenantListPage() {
value="list"
className="flex-1 flex flex-col min-h-0 m-0"
>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div
ref={tenantTableScrollRef}
className="flex-1 overflow-auto relative custom-scrollbar"
data-testid="tenant-table-scroll"
onScroll={handleTenantTableScroll}
>
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table className="min-w-[1180px]">
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>
@@ -1030,18 +844,7 @@ function TenantListPage() {
</TableHead>
</TableRow>
</TableHeader>
<TableBody
className={
shouldVirtualizeTenants ? "relative block" : undefined
}
style={
shouldVirtualizeTenants
? {
height: `${tenantRowVirtualizer.getTotalSize()}px`,
}
: undefined
}
>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={9}>
@@ -1062,26 +865,102 @@ function TenantListPage() {
</TableCell>
</TableRow>
)}
{shouldVirtualizeTenants
? virtualTenantRows.map((virtualRow) => {
const tenant = tenants[virtualRow.index];
if (!tenant) {
return null;
}
return renderTenantRow(tenant, {
virtualIndex: virtualRow.index,
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
display: "table",
tableLayout: "fixed",
transform: `translateY(${virtualRow.start}px)`,
},
});
})
: tenants.map((tenant) => renderTenantRow(tenant))}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant, !!checked)
}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`tenant-internal-id-${tenant.id}`}
>
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/tenants/${tenant.id}`}
className="hover:underline text-primary cursor-pointer"
>
{tenant.name}
</Link>
{isSeedTenant(tenant) && (
<Badge
variant="secondary"
className="text-[10px]"
>
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(
`ui.common.status.${tenant.status}`,
tenant.status,
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.recursiveMemberCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{tenant.createdAt
? new Date(tenant.createdAt).toLocaleString(
"ko-KR",
)
: "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={
isSeedTenant(tenant) || deleteMutation.isPending
}
onClick={() =>
handleDelete(tenant.id, tenant.name)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

View File

@@ -24,6 +24,10 @@ import {
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -555,8 +559,8 @@ function UserListPage() {
</div>
)}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>