import { useMutation, useQuery } from "@tanstack/react-query"; 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 { sortItems, toggleSort, type SortConfig, type SortResolverMap, } from "../../../../../common/core/utils"; 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 { Input } from "../../../components/ui/input"; import { ScrollArea } from "../../../components/ui/scroll-area"; import { Separator } from "../../../components/ui/separator"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../../components/ui/tabs"; import { type TenantSummary, deleteTenant, deleteTenantsBulk, exportTenantsCSV, fetchMe, fetchTenants, importTenantsCSV, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; import { isSeedTenant } from "../utils/protectedTenants"; import { type TenantImportPreviewRow, type TenantImportResolution, buildTenantImportParentOptionGroups, buildTenantImportPreview, inferTenantImportRootParentSlug, parseTenantCSV, serializeTenantImportCSV, } from "../utils/tenantCsvImport"; const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain\n"; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; 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 [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list"); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); const [sortConfig, setSortConfig] = React.useState | null>(null); 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 { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); // Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list React.useEffect(() => { if (profile?.role === "tenant_admin") { const manageableCount = profile.manageableTenants?.length ?? 0; if ( (manageableCount === 1 || manageableCount === 0) && profile.tenantId ) { navigate(`/tenants/${profile.tenantId}`, { replace: true }); } } }, [profile, navigate]); const query = useQuery({ queryKey: ["tenants", { limit: 1000, offset: 0 }], queryFn: () => fetchTenants(1000, 0), enabled: profile?.role === "super_admin" || (profile?.role === "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 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", "테넌트 가져오기에 실패했습니다.", ), ); }, }); if ( profile && profile.role !== "super_admin" && profile.role !== "tenant_admin" ) { return (

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

); } if ( profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 ) { return null; } const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response ?.data?.error; const fallbackError = !errorMsg && query.isError ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; const allTenants = query.data?.items ?? []; const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); const tenantSortResolvers = React.useMemo< SortResolverMap >( () => ({ recursiveMemberCount: (tenant) => tenant.recursiveMemberCount, }), [], ); const tenants = React.useMemo(() => { // 1. Calculate recursive counts // buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally. // However, to easily map them back to a flat list, we can just run the builder, // and then extract the recursive counts. const treeResult = buildTenantFullTree(allTenants); // Flatten the tree or just extract from allTenants map? // buildTenantFullTree does NOT mutate the objects passed in allTenants. It creates new ones. // Let's create a map of id -> recursiveMemberCount const recursiveCounts = new Map(); const extractCounts = (nodes: TenantNode[]) => { for (const node of nodes) { recursiveCounts.set(node.id, node.recursiveMemberCount); if (node.children) extractCounts(node.children); } }; extractCounts(treeResult.subTree); let enriched = allTenants.map((t) => ({ ...t, recursiveMemberCount: recursiveCounts.get(t.id) ?? t.memberCount ?? 0, })); if (search.trim()) { const term = search.toLowerCase(); enriched = enriched.filter( (t) => t.name.toLowerCase().includes(term) || t.slug.toLowerCase().includes(term), ); } return sortItems(enriched, sortConfig, tenantSortResolvers); }, [allTenants, search, sortConfig, tenantSortResolvers]); 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( () => tenants.filter((tenant) => !isSeedTenant(tenant)), [tenants], ); const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(deletableTenants.map((t) => t.id)); } else { setSelectedIds([]); } }; const handleSelect = (tenant: TenantSummary, checked: boolean) => { if (isSeedTenant(tenant)) { return; } if (checked) { setSelectedIds((prev) => [...prev, tenant.id]); } else { setSelectedIds((prev) => prev.filter((i) => i !== tenant.id)); } }; 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 (

{t("ui.admin.tenants.title", "테넌트 목록")}

{t( "msg.admin.tenants.subtitle", "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )}

setSearch(e.target.value)} />
{selectedIds.length > 0 && ( )}
{importMessage && (
{importMessage}
)}
{t("ui.admin.tenants.registry.title", "Tenant Registry")} {t( "msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { count: query.data?.total ?? 0, }, )}
setViewMode(v as "list" | "hierarchy")} className="w-[280px]" > {t("ui.admin.tenants.view.list", "평면 목록")} {t("ui.admin.tenants.view.hierarchy", "계층 구조")}
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
)}
0 && deletableTenants.length > 0 && selectedIds.length === deletableTenants.length } onCheckedChange={(checked) => handleSelectAll(!!checked) } /> requestSort("id")} >
{t("ui.admin.tenants.table.id", "ID")} {getSortIcon("id")}
requestSort("name")} >
{t("ui.admin.tenants.table.name", "NAME")} {getSortIcon("name")}
requestSort("type")} >
{t("ui.admin.tenants.table.type", "TYPE")} {getSortIcon("type")}
requestSort("slug")} >
{t("ui.admin.tenants.table.slug", "SLUG")} {getSortIcon("slug")}
requestSort("status")} >
{t("ui.admin.tenants.table.status", "STATUS")} {getSortIcon("status")}
requestSort("recursiveMemberCount")} >
{t("ui.admin.tenants.table.members", "MEMBERS")} {getSortIcon("recursiveMemberCount")}
requestSort("updatedAt")} >
{t("ui.admin.tenants.table.updated", "UPDATED")} {getSortIcon("updatedAt")}
{t("ui.common.actions", "액션")}
{query.isLoading && ( {t("msg.common.loading", "로딩 중...")} )} {!query.isLoading && tenants.length === 0 && ( {t( "msg.admin.tenants.empty", "아직 등록된 테넌트가 없습니다.", )} )} {tenants.map((tenant) => ( {isSeedTenant(tenant) ? ( ) : ( handleSelect(tenant, !!checked) } /> )} {tenant.id}
{tenant.name} {isSeedTenant(tenant) && ( {t( "ui.admin.tenants.seed_badge", "초기 설정", )} )}
{tenant.type} {tenant.slug} {t( `ui.common.status.${tenant.status}`, tenant.status, )} {tenant.recursiveMemberCount} {tenant.updatedAt ? new Date(tenant.updatedAt).toLocaleString( "ko-KR", ) : "-"}
))}
{t("ui.admin.tenants.import_preview.title", "CSV 가져오기 확인")} {t( "msg.admin.tenants.import_preview.description", "tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다.", )}
{t("ui.common.row", "행")} {t("ui.admin.tenants.table.name", "NAME")} {t("ui.admin.tenants.table.slug", "SLUG")} {t("ui.admin.tenants.import_preview.parent", "상위")} {t("ui.admin.tenants.import_preview.match", "매칭")} {t("ui.admin.tenants.import_preview.candidates", "후보")} {previewRows.map((preview) => ( {preview.row.rowNumber} {preview.row.name} {preview.row.slug} {preview.conflicts.length > 0 && (
{preview.conflicts.map((conflict) => ( {conflict === "external_tenant_id" ? t( "ui.admin.tenants.import_preview.external_id", "외부 ID", ) : conflict === "slug_exists" ? t( "ui.admin.tenants.import_preview.slug_exists", "slug 충돌", ) : t( "ui.admin.tenants.import_preview.parent_unresolved", "부모 확인 필요", )} ))}
)}
{(selectedMatches[preview.row.rowNumber] ?? "__create__") === "__create__" && ( setSelectedCreateSlugs((prev) => ({ ...prev, [preview.row.rowNumber]: event.target.value, })) } /> )}
{preview.candidates.length > 0 ? (
{preview.candidates.map((candidate) => ( = 0.95 ? "default" : "outline" } data-testid="tenant-import-candidate" > {candidate.name}{" "} {Math.round(candidate.score * 100)}% ))}
) : ( {t( "ui.admin.tenants.import_preview.no_candidates", "후보 없음", )} )}
))}
); } // --- Internal Support Components --- const HierarchyNode: React.FC<{ node: TenantNode; level: number; selectedId: string | null; onSelect: (id: string) => void; isExpandedInitial?: boolean; }> = ({ node, level, selectedId, onSelect, isExpandedInitial = false }) => { const [isExpanded, setIsExpanded] = React.useState(isExpandedInitial); const isSelected = selectedId === node.id; const hasChildren = node.children && node.children.length > 0; const TypeIcon = getTenantIcon(node.type); return (
{isExpanded && hasChildren && (
{node.children.map((child) => ( ))}
)}
); }; const TenantHierarchyView: React.FC<{ tenants: TenantSummary[]; }> = ({ tenants }) => { const navigate = useNavigate(); const { subTree } = React.useMemo( () => buildTenantFullTree(tenants), [tenants], ); const [selectedId, setSelectedId] = React.useState( subTree[0]?.id || null, ); const tenantMap = React.useMemo(() => { const m = new Map(); const fill = (nodes: TenantNode[]) => { for (const n of nodes) { m.set(n.id, n); if (n.children) fill(n.children); } }; fill(subTree); return m; }, [subTree]); const selectedNode = selectedId ? tenantMap.get(selectedId) : null; const siblings = React.useMemo(() => { if (!selectedNode) return []; if (!selectedNode.parentId) return subTree; const parent = tenantMap.get(selectedNode.parentId); return parent?.children ?? []; }, [selectedNode, subTree, tenantMap]); return (
{/* Sidebar: Tree */} 조직 계층도
{subTree.map((node) => ( ))}
{/* Main: Details & Lists */}
{selectedNode ? ( <>
{React.createElement(getTenantIcon(selectedNode.type), { size: 24, })}
{selectedNode.name} {selectedNode.slug} ({selectedNode.type})
{/* Siblings (Same Depth) */} 동일 레벨 조직 ({siblings.length})
{siblings.map((s) => (
setSelectedId(s.id)} onKeyUp={(e) => { if (e.key === "Enter" || e.key === " ") { setSelectedId(s.id); } }} className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors border ${ s.id === selectedId ? "bg-primary/5 border-primary/20" : "hover:bg-muted border-transparent" }`} >
{React.createElement(getTenantIcon(s.type), { size: 14, className: "text-muted-foreground", })} {s.name}
{s.recursiveMemberCount}명
))}
{/* Children */} 하위 조직 ({selectedNode.children.length})
{selectedNode.children.map((c) => (
setSelectedId(c.id)} onKeyUp={(e) => { if (e.key === "Enter" || e.key === " ") { setSelectedId(c.id); } }} className="flex items-center justify-between p-2 rounded-md hover:bg-muted cursor-pointer transition-colors border border-transparent" >
{React.createElement(getTenantIcon(c.type), { size: 14, className: "text-muted-foreground", })} {c.name}
{c.recursiveMemberCount}명
))} {selectedNode.children.length === 0 && (
하위 조직이 없습니다.
)}
) : (
조직을 선택하세요.
)}
); }; export default TenantListPage;