import { type UseMutationResult, useInfiniteQuery, useMutation, useQuery, } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { ArrowDown, ArrowUp, ArrowUpDown, Building2, ChevronDown, ChevronRight, Download, ExternalLink, FileSpreadsheet, LayoutDashboard, List, Network, Plus, RefreshCw, Search, Trash2, Upload, } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; import { PageHeader } from "../../../../../common/core/components/page"; import { SortableTableHead, sortableTableHeadBaseClassName, sortableTableHeaderClassName, } from "../../../../../common/core/components/sort"; import { type SortConfig, type SortResolverMap, sortItems, toggleSort, } from "../../../../../common/core/utils"; import { commonStickyTableHeaderClass, commonTableShellClass, commonTableViewportClass, } from "../../../../../common/ui/table"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../../components/ui/card"; import { Checkbox } from "../../../components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { Input } from "../../../components/ui/input"; import { ScrollArea } from "../../../components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../../components/ui/select"; import { Separator } from "../../../components/ui/separator"; import { Switch } from "../../../components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { toast } from "../../../components/ui/use-toast"; import type { UserProfileResponse } from "../../../lib/adminApi"; import { deleteTenant, deleteTenantsBulk, exportTenantsCSV, fetchMe, fetchTenants, importTenantsCSV, type TenantSummary, updateTenant, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { normalizeAdminRole } from "../../../lib/roles"; import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree"; import { buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, isHanmacFamilyUser, parseOrgChartTenantSelection, } from "../../users/orgChartPicker"; import { isSeedTenant } from "../utils/protectedTenants"; import { buildTenantImportParentOptionGroups, buildTenantImportPreview, inferTenantImportRootParentSlug, parseTenantCSV, serializeTenantImportCSV, type TenantImportPreviewRow, type TenantImportResolution, } from "../utils/tenantCsvImport"; import { filterTenantsByScope, getTenantViewRows, resolveTenantSelectionIds, type TenantViewMode, type TenantViewRow, tenantMatchesListSearch, } from "./tenantListView"; 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()) { case "COMPANY_GROUP": return Network; case "ORGANIZATION": case "USER_GROUP": return Network; default: return Building2; } }; const noImportParentRef = "__none__"; function tenantParentRef(tenantId: string) { return `tenant:${tenantId}`; } function previewParentRef(rowNumber: number) { return `row:${rowNumber}`; } function slugParentRef(slug: string) { return `slug:${slug}`; } function getImportParentGroupLabel(type: string) { switch (type) { case "COMPANY_GROUP": return t( "ui.admin.tenants.import_preview.parent_company_groups", "기존 Company Group", ); case "COMPANY": return t( "ui.admin.tenants.import_preview.parent_companies", "기존 Company", ); case "ORGANIZATION": return t( "ui.admin.tenants.import_preview.parent_organizations", "기존 Organization", ); default: return type; } } function resolveDefaultImportParentRef( preview: TenantImportPreviewRow, previewRows: TenantImportPreviewRow[], tenants: TenantSummary[], ) { if (preview.row.parentTenantId) { const parentPreview = previewRows.find( (candidate) => candidate.row.rowNumber !== preview.row.rowNumber && candidate.row.tenantId === preview.row.parentTenantId, ); if (parentPreview) { return previewParentRef(parentPreview.row.rowNumber); } return tenantParentRef(preview.row.parentTenantId); } if (!preview.row.parentTenantSlug) { return noImportParentRef; } const normalizedSlug = preview.row.parentTenantSlug.toLowerCase(); const existingTenant = tenants.find( (tenant) => tenant.slug.toLowerCase() === normalizedSlug, ); if (existingTenant) { return tenantParentRef(existingTenant.id); } const parentPreview = previewRows.find( (candidate) => candidate.row.rowNumber !== preview.row.rowNumber && candidate.row.slug.toLowerCase() === normalizedSlug, ); if (parentPreview) { return previewParentRef(parentPreview.row.rowNumber); } return slugParentRef(preview.row.parentTenantSlug); } function selectedImportSlug( preview: TenantImportPreviewRow, selectedCreateSlugs: Record, ) { return ( selectedCreateSlugs[preview.row.rowNumber] || preview.defaultCreateSlug ); } function resolveImportParentSelection( parentRef: string, previewRows: TenantImportPreviewRow[], selectedMatches: Record, selectedCreateSlugs: Record, ) { if (!parentRef || parentRef === noImportParentRef) { return { parentTenantId: "", parentTenantSlug: "" }; } if (parentRef.startsWith("tenant:")) { return { parentTenantId: parentRef.slice("tenant:".length), parentTenantSlug: "", }; } if (parentRef.startsWith("slug:")) { return { parentTenantSlug: parentRef.slice("slug:".length) }; } if (parentRef.startsWith("row:")) { const rowNumber = Number(parentRef.slice("row:".length)); const selected = selectedMatches[rowNumber] ?? "__create__"; if (selected && selected !== "__create__") { return { parentTenantId: selected, parentTenantSlug: "" }; } const parentPreview = previewRows.find( (preview) => preview.row.rowNumber === rowNumber, ); return { parentTenantSlug: parentPreview ? selectedImportSlug(parentPreview, selectedCreateSlugs) : "", }; } return {}; } function TenantListPage() { const navigate = useNavigate(); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); const [viewMode, setViewMode] = React.useState("tree"); const [scopeTenantId, setScopeTenantId] = React.useState(""); const [scopePickerOpen, setScopePickerOpen] = React.useState(false); const [sortConfig, setSortConfig] = React.useState | null>({ key: "createdAt", direction: "desc", }); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< TenantImportPreviewRow[] >([]); const [selectedMatches, setSelectedMatches] = React.useState< Record >({}); const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState< Record >({}); const [selectedParentRefs, setSelectedParentRefs] = React.useState< Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); const tenantTableScrollRef = React.useRef(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") { const manageableCount = profile.manageableTenants?.length ?? 0; if ( (manageableCount === 1 || manageableCount === 0) && profile.tenantId ) { navigate(`/tenants/${profile.tenantId}`, { replace: true }); } } }, [profile, profileRole, 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, enabled: profileRole === "super_admin" || (profileRole === "tenant_admin" && (profile?.manageableTenants?.length ?? 0) > 1), }); const deleteMutation = useMutation({ mutationFn: (tenantId: string) => deleteTenant(tenantId), onSuccess: () => { query.refetch(); }, }); const deleteBulkMutation = useMutation({ mutationFn: (ids: string[]) => deleteTenantsBulk(ids), onSuccess: () => { setSelectedIds([]); query.refetch(); }, }); const statusMutation = useMutation({ mutationFn: ({ tenantId, status }: { tenantId: string; status: string }) => updateTenant(tenantId, { status }), onSuccess: () => { query.refetch(); }, onError: () => { toast.error( t("msg.admin.tenants.status_error", "테넌트 상태 변경에 실패했습니다."), ); }, }); const bulkUpdateStatusMutation = useMutation({ mutationFn: async ({ tenantIds, status, }: { tenantIds: string[]; status: string; }) => { // Execute sequential updates to avoid rate limits or partial failures await Promise.all(tenantIds.map((id) => updateTenant(id, { status }))); }, onSuccess: () => { query.refetch(); setSelectedIds([]); setSelectedBulkStatus(""); toast.success( t( "msg.admin.tenants.bulk.update_success", "선택한 테넌트들의 상태가 수정되었습니다.", ), ); }, onError: () => { toast.error( t( "msg.admin.tenants.bulk.update_error", "테넌트 일괄 상태 변경에 실패했습니다.", ), ); }, }); const handleApplyBulkStatus = () => { if (selectedIds.length === 0 || !selectedBulkStatus) return; bulkUpdateStatusMutation.mutate({ tenantIds: selectedIds, status: selectedBulkStatus, }); }; const exportMutation = useMutation({ mutationFn: (includeIds: boolean) => exportTenantsCSV(includeIds), onSuccess: ({ blob, filename }) => { const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); }, }); const importMutation = useMutation({ mutationFn: (file: File) => importTenantsCSV(file), onSuccess: (result) => { setImportMessage( t( "msg.admin.tenants.import_result", "생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}", { created: result.created, updated: result.updated, failed: result.failed, }, ), ); setPreviewOpen(false); setPreviewRows([]); setSelectedMatches({}); setSelectedParentRefs({}); query.refetch(); }, onError: (error: AxiosError<{ error?: string }>) => { setImportMessage( error.response?.data?.error ?? t( "msg.admin.tenants.import_error", "테넌트 가져오기에 실패했습니다.", ), ); }, }); const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response ?.data?.error; const fallbackError = !errorMsg && query.isError ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; const tenantPages = React.useMemo( () => query.data?.pages ?? [], [query.data?.pages], ); const rawTenants = React.useMemo( () => tenantPages.flatMap((page) => page.items), [tenantPages], ); 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 scopedTenants = React.useMemo( () => filterTenantsByScope(allTenants, scopeTenantId), [allTenants, scopeTenantId], ); const selectedScopeTenant = React.useMemo( () => allTenants.find((tenant) => tenant.id === scopeTenantId), [allTenants, scopeTenantId], ); const scopePickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, hanmacFamilyTenantId ? { tenantId: hanmacFamilyTenantId } : {}, ); const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); const requestSort = (key: TenantSortKey) => { setSortConfig((current) => toggleSort(current, key)); }; const getSortIcon = (key: TenantSortKey) => { if (!sortConfig || sortConfig.key !== key) { return ; } return sortConfig.direction === "asc" ? ( ) : ( ); }; const deletableTenants = React.useMemo( () => scopedTenants.filter((tenant) => !isSeedTenant(tenant)), [scopedTenants], ); React.useEffect(() => { const selectableIds = new Set(deletableTenants.map((tenant) => tenant.id)); setSelectedIds((prev) => { const next = prev.filter((id) => selectableIds.has(id)); if (next.length === prev.length) { return prev; } return next; }); }, [deletableTenants]); React.useEffect(() => { if (!scopePickerOpen) return; const onMessage = (event: MessageEvent) => { const selection = parseOrgChartTenantSelection(event.data); if (!selection) return; if (!allTenants.some((tenant) => tenant.id === selection.id)) return; setScopeTenantId(selection.id); setScopePickerOpen(false); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [allTenants, scopePickerOpen]); if ( profile && profileRole !== "super_admin" && profileRole !== "tenant_admin" ) { return (

{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}

); } if ( profileRole === "tenant_admin" && (profile?.manageableTenants?.length ?? 0) <= 1 ) { return null; } const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(deletableTenants.map((t) => t.id)); } else { setSelectedIds([]); } }; const handleSelect = (tenant: TenantSummary, checked: boolean) => { if (isSeedTenant(tenant)) { return; } setSelectedIds((prev) => resolveTenantSelectionIds({ currentIds: prev, tenant, checked, tenants: allTenants, deletableTenants, }), ); }; const handleDeleteBulk = () => { if (selectedIds.length === 0) return; const deletableIds = selectedIds.filter((id) => deletableTenants.some((tenant) => tenant.id === id), ); if (deletableIds.length === 0) return; if ( !window.confirm( t( "msg.admin.tenants.delete_bulk_confirm", "선택한 {{count}}개 테넌트를 삭제할까요?", { count: deletableIds.length }, ), ) ) { return; } deleteBulkMutation.mutate(deletableIds); }; const handleTemplateDownload = () => { const blob = new Blob([tenantCSVTemplate], { type: "text/csv" }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "tenant-import-template.csv"; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); }; const handleImportFile = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; setImportMessage(""); const text = await file.text(); const rows = parseTenantCSV(text, { rootParentSlug: inferTenantImportRootParentSlug(file.name, allTenants), }); if (rows.length === 0) { setImportMessage( t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."), ); return; } const preview = buildTenantImportPreview(rows, allTenants); setPreviewRows(preview); setSelectedMatches( Object.fromEntries( preview.map((row) => [ row.row.rowNumber, row.defaultTenantId || "__create__", ]), ), ); setSelectedCreateSlugs( Object.fromEntries( preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]), ), ); setSelectedParentRefs( Object.fromEntries( preview.map((row) => [ row.row.rowNumber, resolveDefaultImportParentRef(row, preview, allTenants), ]), ), ); setPreviewOpen(true); }; const handleImportConfirm = () => { const resolutions: Record = Object.fromEntries( previewRows.map((preview) => { const selected = selectedMatches[preview.row.rowNumber] ?? ""; if (selected && selected !== "__create__") { return [ preview.row.rowNumber, { mode: "existing", tenantId: selected, ...resolveImportParentSelection( selectedParentRefs[preview.row.rowNumber] ?? resolveDefaultImportParentRef( preview, previewRows, allTenants, ), previewRows, selectedMatches, selectedCreateSlugs, ), }, ]; } return [ preview.row.rowNumber, { mode: "create", slug: selectedCreateSlugs[preview.row.rowNumber] || preview.defaultCreateSlug, ...resolveImportParentSelection( selectedParentRefs[preview.row.rowNumber] ?? resolveDefaultImportParentRef( preview, previewRows, allTenants, ), previewRows, selectedMatches, selectedCreateSlugs, ), }, ]; }), ); const csv = serializeTenantImportCSV(previewRows, resolutions); const file = new File([csv], "tenants.csv", { type: "text/csv" }); importMutation.mutate(file); }; const handleDelete = (tenantId: string, tenantName: string) => { const tenant = allTenants.find((item) => item.id === tenantId); if (tenant && isSeedTenant(tenant)) { return; } if ( !window.confirm( t( "msg.admin.tenants.delete_confirm", '테넌트 "{{name}}"를 삭제할까요?', { name: tenantName }, ), ) ) { return; } deleteMutation.mutate(tenantId); }; return (
} title={t("ui.admin.tenants.title", "테넌트 목록")} description={t( "msg.admin.tenants.subtitle", "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )} actions={ <>
setSearch(e.target.value)} />
{scopeTenantId ? ( ) : null} {t("ui.admin.tenants.csv_template", "템플릿 다운로드")} fileInputRef.current?.click()} disabled={importMutation.isPending} data-testid="tenant-import-menu-item" className="cursor-pointer" > {t("ui.admin.tenants.import", "CSV 가져오기")} exportMutation.mutate(false)} disabled={exportMutation.isPending} data-testid="tenant-export-menu-item" className="cursor-pointer" > {t( "ui.admin.tenants.export_without_ids", "UUID 제외 내보내기", )} exportMutation.mutate(true)} disabled={exportMutation.isPending} data-testid="tenant-export-with-ids-menu-item" className="cursor-pointer" > {t( "ui.admin.tenants.export_with_ids", "UUID 포함 내보내기", )}
{importMessage ? (
{importMessage}
) : null} } />
{t("ui.admin.tenants.registry.title", "Tenant Registry")} {t( "msg.admin.tenants.registry.count", "총 {{count}}개의 테넌트가 등록되어 있습니다.", { count: scopeTenantId ? scopedTenants.length : tenantTotal, }, )}
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
)}
{t("ui.admin.tenants.scope.pick", "상위 범위 선택")} {t( "msg.admin.tenants.scope.description", "orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.", )}