import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Download, FileSpreadsheet, Pencil, Plus, RefreshCw, Search, Trash2, Upload, } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { type TenantSummary, deleteTenant, deleteTenantsBulk, exportTenantsCSV, fetchMe, fetchTenants, importTenantsCSV, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { type TenantImportPreviewRow, buildTenantImportPreview, parseTenantCSV, serializeTenantImportCSV, } from "../utils/tenantCsvImport"; const tenantCSVTemplate = "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n"; function TenantListPage() { const navigate = useNavigate(); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); const fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< TenantImportPreviewRow[] >([]); const [selectedMatches, setSelectedMatches] = 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: exportTenantsCSV, 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({}); 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 tenants = React.useMemo(() => { if (!search.trim()) return allTenants; const term = search.toLowerCase(); return allTenants.filter( (t) => t.name.toLowerCase().includes(term) || t.slug.toLowerCase().includes(term), ); }, [allTenants, search]); const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(tenants.map((t) => t.id)); } else { setSelectedIds([]); } }; const handleSelect = (id: string, checked: boolean) => { if (checked) { setSelectedIds((prev) => [...prev, id]); } else { setSelectedIds((prev) => prev.filter((i) => i !== id)); } }; const handleDeleteBulk = () => { if (selectedIds.length === 0) return; if ( !window.confirm( t( "msg.admin.tenants.delete_bulk_confirm", "선택한 {{count}}개 테넌트를 삭제할까요?", { count: selectedIds.length }, ), ) ) { return; } deleteBulkMutation.mutate(selectedIds); }; 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); if (rows.length === 0) { setImportMessage( t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."), ); return; } const preview = buildTenantImportPreview(rows, allTenants); setPreviewRows(preview); setSelectedMatches( Object.fromEntries( preview .filter((row) => row.defaultTenantId) .map((row) => [row.row.rowNumber, row.defaultTenantId]), ), ); setPreviewOpen(true); }; const handleImportConfirm = () => { const csv = serializeTenantImportCSV(previewRows, selectedMatches); const file = new File([csv], "tenants.csv", { type: "text/csv" }); importMutation.mutate(file); }; const handleDelete = (tenantId: string, tenantName: string) => { 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", "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )}

{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, })}
setSearch(e.target.value)} />
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
)}
0 && selectedIds.length === tenants.length } onCheckedChange={(checked) => handleSelectAll(!!checked) } /> {t("ui.admin.tenants.table.id", "ID")} {t("ui.admin.tenants.table.name", "NAME")} {t("ui.admin.tenants.table.type", "TYPE")} {t("ui.admin.tenants.table.slug", "SLUG")} {t("ui.admin.tenants.table.status", "STATUS")} {t("ui.admin.tenants.table.members", "MEMBERS")} {t("ui.admin.tenants.table.updated", "UPDATED")} {t("ui.admin.tenants.table.actions", "ACTIONS")} {query.isLoading && ( {t("msg.common.loading", "로딩 중...")} )} {!query.isLoading && tenants.length === 0 && ( {t( "msg.admin.tenants.empty", "아직 등록된 테넌트가 없습니다.", )} )} {tenants.map((tenant) => ( handleSelect(tenant.id, !!checked) } /> {tenant.id} {tenant.name} {t( `domain.tenant_type.${tenant.type?.toLowerCase()}`, tenant.type, )} {tenant.slug} {t( `ui.common.status.${tenant.status}`, tenant.status, )} {tenant.memberCount} {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.match", "매칭")} {t("ui.admin.tenants.import_preview.candidates", "후보")} {previewRows.map((preview) => ( {preview.row.rowNumber} {preview.row.name} {preview.row.slug} {preview.row.tenantId ? ( {t( "ui.admin.tenants.import_preview.fixed_id", "ID 지정됨", )} ) : ( )} {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", "후보 없음", )} )}
))}
); } export default TenantListPage;