import { 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, FileSpreadsheet, LayoutDashboard, List, Network, Plus, RefreshCw, Search, Trash2, Upload, } from "lucide-react"; import * as React from "react"; import { Link, useNavigate, useOutletContext } from "react-router-dom"; import { PageHeader } from "../../../../../common/core/components/page"; import { type SortConfig, type SortResolverMap, sortItems, toggleSort, } from "../../../../../common/core/utils"; import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../../components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; import { deleteTenantsBulk, exportTenantsCSV, fetchMe, fetchTenants, importTenantsCSV, type TenantImportDetail, type TenantImportResult, type TenantSummary, updateTenant, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { normalizeAdminRole } from "../../../lib/roles"; import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree"; import { cn } from "../../../lib/utils"; 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 { TENANT_VISIBILITY_OPTIONS, type TenantVisibility, } from "../utils/orgConfig"; import { filterTenantsByScope, filterTenantViewRowsBySearch, getTenantSearchMatchIds, getTenantViewRows, resolveTenantSelectionIds, type TenantViewMode, type TenantViewRow, } from "./tenantListView"; const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n"; const tenantPageSize = 500; const _tenantVirtualizationThreshold = 250; const _tenantEstimatedRowHeight = 73; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; 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; const backendTenantSortKeys = new Set([ "createdAt", "id", "name", "slug", "status", "type", "updatedAt", ]); const bulkTenantTypeOptions = [ { value: "COMPANY", label: "COMPANY (일반 기업)" }, { value: "COMPANY_GROUP", label: "COMPANY_GROUP (그룹사/지주사)" }, { value: "ORGANIZATION", label: "ORGANIZATION (정규 조직)" }, { value: "USER_GROUP", label: "USER_GROUP (내부 부서/팀)" }, { value: "PERSONAL", label: "PERSONAL (개인 워크스페이스)" }, ] as const; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { case "COMPANY_GROUP": return Network; case "ORGANIZATION": case "USER_GROUP": return Network; default: return Building2; } }; function getTenantTypeLabel(type?: string) { if (!type) return "-"; 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": return "text-sky-700"; case "COMPANY": return "text-violet-700"; case "ORGANIZATION": return "text-emerald-700"; case "USER_GROUP": return "text-amber-700"; case "PERSONAL": return "text-slate-700"; default: return "text-muted-foreground"; } } function buildTenantParentPathMap(tenants: TenantSummary[]) { const tenantById = new Map(tenants.map((tenant) => [tenant.id, tenant])); const pathMap = new Map(); for (const tenant of tenants) { const names: string[] = []; const visited = new Set(); let currentParentId = tenant.parentId; while (currentParentId && !visited.has(currentParentId)) { visited.add(currentParentId); const parent = tenantById.get(currentParentId); if (!parent) break; names.unshift(parent.name); currentParentId = parent.parentId; } pathMap.set(tenant.id, names); } return pathMap; } 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 [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 [importResult, setImportResult] = React.useState(null); const [importResultOpen, setImportResultOpen] = React.useState(false); const [importResultFilter, setImportResultFilter] = React.useState< "all" | "created" | "updated" | "failed" | "skipped" >("all"); const filteredImportDetails = React.useMemo(() => { if (!importResult) return []; if (importResultFilter === "all") return importResult.details; if (importResultFilter === "failed") return importResult.details.filter((d: TenantImportDetail) => !d.success); return importResult.details.filter( (d: TenantImportDetail) => d.action === importResultFilter, ); }, [importResult, importResultFilter]); const [search, setSearch] = React.useState(""); const debouncedSearch = React.useDeferredValue(search.trim()); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); const [selectedBulkType, setSelectedBulkType] = React.useState(""); const [selectedBulkVisibility, setSelectedBulkVisibility] = React.useState< TenantVisibility | "" >(""); const _tenantTableScrollRef = React.useRef(null); const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); const profileRole = normalizeAdminRole(profile?.role); const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_tenants; const backendSortKey = sortConfig && backendTenantSortKeys.has(sortConfig.key) ? sortConfig.key : undefined; const query = useInfiniteQuery({ queryKey: [ "tenants", "lazy", debouncedSearch, scopeTenantId, backendSortKey, sortConfig?.direction, ], queryFn: ({ pageParam }) => fetchTenants( tenantPageSize, 0, scopeTenantId || undefined, pageParam ? (pageParam as string) : undefined, debouncedSearch, backendSortKey, sortConfig?.direction, ), initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || lastPage.next_cursor || undefined, }); const rawTenants = React.useMemo( () => query.data?.pages.flatMap((page) => page.items) ?? [], [query.data?.pages], ); const deleteBulkMutation = useMutation({ mutationFn: (ids: string[]) => deleteTenantsBulk(ids), onSuccess: () => { setSelectedIds([]); query.refetch(); }, }); const bulkUpdateTenantsMutation = useMutation({ mutationFn: async ({ tenantIds, status, type, visibility, }: { tenantIds: string[]; status?: string; type?: string; visibility?: TenantVisibility; }) => { await Promise.all( tenantIds.map((id) => { const source = rawTenants.find((tenant) => tenant.id === id); return updateTenant(id, { ...(status ? { status } : {}), ...(type ? { type } : {}), ...(visibility ? { config: { ...(source?.config ?? {}), visibility } } : {}), }); }), ); }, onSuccess: () => { query.refetch(); setSelectedIds([]); setSelectedBulkStatus(""); setSelectedBulkType(""); setSelectedBulkVisibility(""); 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 && !selectedBulkType && !selectedBulkVisibility) ) { return; } bulkUpdateTenantsMutation.mutate({ tenantIds: selectedIds, ...(selectedBulkStatus ? { status: selectedBulkStatus } : {}), ...(selectedBulkType ? { type: selectedBulkType } : {}), ...(selectedBulkVisibility ? { visibility: selectedBulkVisibility } : {}), }); }; 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) => { setImportResult(result); setImportResultOpen(true); 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 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" && !profile?.systemPermissions?.tenants ) { return (

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

); } 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); }; return (
} title={t("ui.admin.tenants.title", "테넌트 목록")} description={t( "msg.admin.tenants.subtitle", "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )} actions={
setSearch(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { query.refetch(); } }} />
{scopeTenantId ? ( ) : null} } actions={ <> {isWritable && ( <> {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 포함 내보내기", )} )} {isWritable && ( )} } /> {importMessage ? (
{importMessage}
) : null}
} />
{t("ui.admin.tenants.registry.title", "Tenant Registry")} {t( "msg.admin.tenants.registry.count", "총 {{count}}개의 테넌트가 등록되어 있습니다.", { count: scopeTenantId ? scopedTenants.length : allTenants.length, }, )}
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
)}
{t("ui.admin.tenants.scope.pick", "상위 범위 선택")} {t( "msg.admin.tenants.scope.description", "orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.", )}