"use client"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Search, Database, RefreshCw, Save, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown, Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { useMultiLang } from "@/hooks/useMultiLang"; import { useAuth } from "@/hooks/useAuth"; import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement"; import { INPUT_TYPE_OPTIONS } from "@/types/input-types"; import { apiClient } from "@/lib/api/client"; import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { ddlApi } from "@/lib/api/ddl"; import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue"; import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule"; import { NumberingRuleConfig } from "@/types/numbering-rule"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; import { TableLogViewer } from "@/components/admin/TableLogViewer"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/admin/table-type/types"; import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip"; import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid"; import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel"; export default function TableManagementPage() { const { userLang, getText } = useMultiLang({ companyCode: "*" }); const { user } = useAuth(); const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [selectedTable, setSelectedTable] = useState(null); const [searchTerm, setSearchTerm] = useState(""); const [loading, setLoading] = useState(false); const [columnsLoading, setColumnsLoading] = useState(false); const [originalColumns, setOriginalColumns] = useState([]); // 원본 데이터 저장 const [uiTexts, setUiTexts] = useState>({}); // 페이지네이션 상태 const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(9999); // 전체 컬럼 표시 const [totalColumns, setTotalColumns] = useState(0); // 테이블 라벨 상태 const [tableLabel, setTableLabel] = useState(""); const [tableDescription, setTableDescription] = useState(""); // 🎯 Entity 조인 관련 상태 const [referenceTableColumns, setReferenceTableColumns] = useState>({}); // 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리) const [entityComboboxOpen, setEntityComboboxOpen] = useState< Record< string, { table: boolean; joinColumn: boolean; displayColumn: boolean; } > >({}); // DDL 기능 관련 상태 const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); // 테이블 복제 관련 상태 const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create"); const [duplicateSourceTable, setDuplicateSourceTable] = useState(null); // 🆕 Category 타입용: 2레벨 메뉴 목록 const [secondLevelMenus, setSecondLevelMenus] = useState([]); // 🆕 Numbering 타입용: 채번규칙 목록 const [numberingRules, setNumberingRules] = useState([]); const [numberingRulesLoading, setNumberingRulesLoading] = useState(false); const [numberingComboboxOpen, setNumberingComboboxOpen] = useState>({}); // 로그 뷰어 상태 const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); // 저장 중 상태 (중복 실행 방지) const [isSaving, setIsSaving] = useState(false); // 테이블 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); // PK/인덱스 관리 상태 const [constraints, setConstraints] = useState<{ primaryKey: { name: string; columns: string[] }; indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>; }>({ primaryKey: { name: "", columns: [] }, indexes: [] }); const [pkDialogOpen, setPkDialogOpen] = useState(false); const [pendingPkColumns, setPendingPkColumns] = useState([]); // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); // 컬럼 그리드: 선택된 컬럼(우측 상세 패널 표시) const [selectedColumn, setSelectedColumn] = useState(null); // 타입 오버뷰 스트립: 타입 필터 (null = 전체) const [typeFilter, setTypeFilter] = useState(null); // 최고 관리자 여부 확인 (회사코드가 "*" AND userType이 "SUPER_ADMIN") const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN"; // 다국어 텍스트 로드 useEffect(() => { const loadTexts = async () => { if (!userLang) return; try { const response = await apiClient.post( "/multilang/batch", { langKeys: Object.values(TABLE_MANAGEMENT_KEYS), }, { params: { companyCode: "*", menuCode: "TABLE_MANAGEMENT", userLang: userLang, }, }, ); if (response.data.success) { setUiTexts(response.data.data); } } catch (error) { // console.error("다국어 텍스트 로드 실패:", error); } }; loadTexts(); }, [userLang]); // 텍스트 가져오기 함수 const getTextFromUI = (key: string, fallback?: string) => { return uiTexts[key] || fallback || key; }; // 🎯 참조 테이블 컬럼 정보 로드 const loadReferenceTableColumns = useCallback( async (tableName: string) => { if (!tableName) { return; } // 이미 로드된 경우이지만 빈 배열이 아닌 경우만 스킵 const existingColumns = referenceTableColumns[tableName]; if (existingColumns && existingColumns.length > 0) { // console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns); return; } // console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`); try { const result = await entityJoinApi.getReferenceTableColumns(tableName); // console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns); setReferenceTableColumns((prev) => ({ ...prev, [tableName]: result.columns, })); } catch (error) { // console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error); setReferenceTableColumns((prev) => ({ ...prev, [tableName]: [], })); } }, [], // 의존성 배열에서 referenceTableColumns 제거 ); // 입력 타입 옵션 (8개 핵심 타입) const inputTypeOptions = INPUT_TYPE_OPTIONS.map((option) => ({ value: option.value, label: option.label, description: option.description, })); // 메모이제이션된 입력타입 옵션 const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []); // 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시) const referenceTableOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, ...tables.map((table) => ({ value: table.tableName, label: table.displayName && table.displayName !== table.tableName ? `${table.displayName} (${table.tableName})` : table.tableName, })), ]; // 공통 코드 카테고리 목록 상태 const [commonCodeCategories, setCommonCodeCategories] = useState>([]); // 공통 코드 옵션 const commonCodeOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_CODE_PLACEHOLDER, "코드 선택") }, ...commonCodeCategories, ]; // 공통코드 카테고리 목록 로드 const loadCommonCodeCategories = async () => { try { const response = await commonCodeApi.categories.getList({ isActive: true }); // console.log("🔍 공통코드 카테고리 API 응답:", response); if (response.success && response.data) { // console.log("📋 공통코드 카테고리 데이터:", response.data); const categories = response.data.map((category) => { // console.log("🏷️ 카테고리 항목:", category); return { value: category.category_code, label: category.category_name || category.category_code, }; }); // console.log("✅ 매핑된 카테고리 옵션:", categories); setCommonCodeCategories(categories); } } catch (error) { // console.error("공통코드 카테고리 로드 실패:", error); // 에러는 로그만 남기고 사용자에게는 알리지 않음 (선택적 기능) } }; // 🆕 2레벨 메뉴 목록 로드 const loadSecondLevelMenus = async () => { try { const response = await getSecondLevelMenus(); if (response.success && response.data) { setSecondLevelMenus(response.data); } else { console.warn("⚠️ 2레벨 메뉴 로드 실패:", response); setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제 } } catch (error) { console.error("❌ 2레벨 메뉴 로드 에러:", error); setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정 } }; // 🆕 채번규칙 목록 로드 const loadNumberingRules = async () => { setNumberingRulesLoading(true); try { const response = await getNumberingRules(); if (response.success && response.data) { setNumberingRules(response.data); } else { console.warn("⚠️ 채번규칙 로드 실패:", response); setNumberingRules([]); } } catch (error) { console.error("❌ 채번규칙 로드 에러:", error); setNumberingRules([]); } finally { setNumberingRulesLoading(false); } }; // 테이블 목록 로드 const loadTables = async () => { setLoading(true); try { const response = await apiClient.get("/table-management/tables"); // 응답 상태 확인 if (response.data.success) { setTables(response.data.data); toast.success("테이블 목록을 성공적으로 로드했습니다."); } else { showErrorToast("테이블 목록을 불러오는 데 실패했습니다", response.data.message, { guidance: "네트워크 연결을 확인해 주세요.", }); } } catch (error) { // console.error("테이블 목록 로드 실패:", error); showErrorToast("테이블 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요.", }); } finally { setLoading(false); } }; // 컬럼 타입 정보 로드 (페이지네이션 적용) const loadColumnTypes = useCallback(async (tableName: string, page: number = 1, size: number = 50) => { setColumnsLoading(true); try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, { params: { page, size }, }); // 응답 상태 확인 if (response.data.success) { const data = response.data.data; console.log("📥 원본 API 응답:", { hasColumns: !!(data.columns || data), firstColumn: (data.columns || data)[0], statusColumn: (data.columns || data).find((col: any) => col.columnName === "status"), }); // 컬럼 데이터에 기본값 설정 const processedColumns = (data.columns || data).map((col: any) => { // detailSettings에서 hierarchyRole, numberingRuleId 추출 let hierarchyRole: "large" | "medium" | "small" | undefined = undefined; let numberingRuleId: string | undefined = undefined; if (col.detailSettings && typeof col.detailSettings === "string") { try { const parsed = JSON.parse(col.detailSettings); if ( parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small" ) { hierarchyRole = parsed.hierarchyRole; } if (parsed.numberingRuleId) { numberingRuleId = parsed.numberingRuleId; } } catch { // JSON 파싱 실패 시 무시 } } return { ...col, inputType: col.inputType || "text", isUnique: col.isUnique || "NO", numberingRuleId, categoryMenus: col.categoryMenus || [], hierarchyRole, categoryRef: col.categoryRef || null, }; }); if (page === 1) { setColumns(processedColumns); setOriginalColumns(processedColumns); } else { // 페이지 추가 로드 시 기존 데이터에 추가 setColumns((prev) => [...prev, ...processedColumns]); } setTotalColumns(data.total || processedColumns.length); toast.success("컬럼 정보를 성공적으로 로드했습니다."); } else { showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", response.data.message, { guidance: "네트워크 연결을 확인해 주세요.", }); } } catch (error) { // console.error("컬럼 타입 정보 로드 실패:", error); showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요.", }); } finally { setColumnsLoading(false); } }, []); // PK/인덱스 제약조건 로드 const loadConstraints = useCallback(async (tableName: string) => { try { const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`); if (response.data.success) { setConstraints(response.data.data); } } catch (error) { console.error("제약조건 로드 실패:", error); setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] }); } }, []); // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { setSelectedTable(tableName); setCurrentPage(1); setColumns([]); setSelectedColumn(null); setTypeFilter(null); // 선택된 테이블 정보에서 라벨 설정 const tableInfo = tables.find((table) => table.tableName === tableName); setTableLabel(tableInfo?.displayName || tableName); setTableDescription(tableInfo?.description || ""); loadColumnTypes(tableName, 1, pageSize); loadConstraints(tableName); }, [loadColumnTypes, loadConstraints, pageSize, tables], ); // 입력 타입 변경 - 이전 타입의 설정값 초기화 포함 const handleInputTypeChange = useCallback( (columnName: string, newInputType: string) => { setColumns((prev) => prev.map((col) => { if (col.columnName === columnName) { const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType); const updated: typeof col = { ...col, inputType: newInputType, detailSettings: inputTypeOption?.description || col.detailSettings, }; // 엔티티가 아닌 타입으로 변경 시 참조 설정 초기화 if (newInputType !== "entity") { updated.referenceTable = undefined; updated.referenceColumn = undefined; updated.displayColumn = undefined; } // 코드가 아닌 타입으로 변경 시 코드 설정 초기화 if (newInputType !== "code") { updated.codeCategory = undefined; updated.codeValue = undefined; updated.hierarchyRole = undefined; } // 카테고리가 아닌 타입으로 변경 시 카테고리 참조 초기화 if (newInputType !== "category") { updated.categoryRef = undefined; } return updated; } return col; }), ); }, [memoizedInputTypeOptions], ); // 상세 설정 변경 (코드/엔티티 타입용) const handleDetailSettingsChange = useCallback( (columnName: string, settingType: string, value: string) => { setColumns((prev) => prev.map((col) => { if (col.columnName === columnName) { let newDetailSettings = col.detailSettings; let codeCategory = col.codeCategory; let codeValue = col.codeValue; let referenceTable = col.referenceTable; let referenceColumn = col.referenceColumn; let displayColumn = col.displayColumn; let hierarchyRole = col.hierarchyRole; if (settingType === "code") { if (value === "none") { newDetailSettings = ""; codeCategory = undefined; codeValue = undefined; hierarchyRole = undefined; // 코드 선택 해제 시 계층 역할도 초기화 } else { // 기존 hierarchyRole 유지하면서 JSON 형식으로 저장 const existingHierarchyRole = hierarchyRole; newDetailSettings = JSON.stringify({ codeCategory: value, hierarchyRole: existingHierarchyRole, }); codeCategory = value; codeValue = value; } } else if (settingType === "hierarchy_role") { // 계층구조 역할 변경 - JSON 형식으로 저장 hierarchyRole = value === "none" ? undefined : (value as "large" | "medium" | "small"); // detailSettings를 JSON으로 업데이트 let existingSettings: Record = {}; if (typeof col.detailSettings === "string" && col.detailSettings.trim().startsWith("{")) { try { existingSettings = JSON.parse(col.detailSettings); } catch { existingSettings = {}; } } newDetailSettings = JSON.stringify({ ...existingSettings, hierarchyRole: hierarchyRole, }); } else if (settingType === "entity") { if (value === "none") { newDetailSettings = ""; referenceTable = undefined; referenceColumn = undefined; displayColumn = undefined; } else { const tableOption = referenceTableOptions.find((option) => option.value === value); newDetailSettings = tableOption ? `참조테이블: ${tableOption.label}` : ""; referenceTable = value; // 🎯 참조 컬럼을 소스 컬럼명과 동일하게 설정 (일반적인 경우) // 예: user_info.dept_code -> dept_info.dept_code referenceColumn = col.columnName; // 참조 테이블의 컬럼 정보 로드 loadReferenceTableColumns(value); } } else if (settingType === "entity_reference_column") { // 🎯 Entity 참조 컬럼 변경 (조인할 컬럼) referenceColumn = value; const tableOption = referenceTableOptions.find((option) => option.value === col.referenceTable); newDetailSettings = tableOption ? `참조테이블: ${tableOption.label}` : ""; } else if (settingType === "entity_display_column") { // 🎯 Entity 표시 컬럼 변경 displayColumn = value; const tableOption = referenceTableOptions.find((option) => option.value === col.referenceTable); newDetailSettings = tableOption ? `참조테이블: ${tableOption.label} (${value})` : ""; } return { ...col, detailSettings: newDetailSettings, codeCategory, codeValue, referenceTable, referenceColumn, displayColumn, hierarchyRole, }; } return col; }), ); }, [commonCodeOptions, referenceTableOptions, loadReferenceTableColumns], ); // 라벨 변경 핸들러 추가 const handleLabelChange = useCallback((columnName: string, newLabel: string) => { setColumns((prev) => prev.map((col) => { if (col.columnName === columnName) { return { ...col, displayName: newLabel, }; } return col; }), ); }, []); // 컬럼 변경 핸들러 (인덱스 기반) const handleColumnChange = useCallback((index: number, field: keyof ColumnTypeInfo, value: any) => { setColumns((prev) => prev.map((col, i) => { if (i === index) { return { ...col, [field]: value, }; } return col; }), ); }, []); // 개별 컬럼 저장 const handleSaveColumn = async (column: ColumnTypeInfo) => { if (!selectedTable) return; try { // 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함 let finalDetailSettings = column.detailSettings || ""; if (column.inputType === "entity" && column.referenceTable) { // 기존 detailSettings를 파싱하거나 새로 생성 let existingSettings: Record = {}; if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) { try { existingSettings = JSON.parse(column.detailSettings); } catch { existingSettings = {}; } } // 엔티티 설정 추가 const entitySettings = { ...existingSettings, entityTable: column.referenceTable, entityCodeColumn: column.referenceColumn || "id", entityLabelColumn: column.displayColumn || "name", placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요", searchable: existingSettings.searchable ?? true, }; finalDetailSettings = JSON.stringify(entitySettings); console.log("🔧 Entity 설정 JSON 생성:", entitySettings); } // 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함 if (column.inputType === "code" && column.hierarchyRole) { let existingSettings: Record = {}; if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { try { existingSettings = JSON.parse(finalDetailSettings); } catch { existingSettings = {}; } } const codeSettings = { ...existingSettings, hierarchyRole: column.hierarchyRole, }; finalDetailSettings = JSON.stringify(codeSettings); console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); } const columnSetting = { columnName: column.columnName, columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", displayColumn: column.displayColumn || "", categoryRef: column.categoryRef || null, }; // console.log("저장할 컬럼 설정:", columnSetting); console.log("💾 저장할 컬럼 정보:", { columnName: column.columnName, inputType: column.inputType, categoryMenus: column.categoryMenus, hasCategoryMenus: !!column.categoryMenus, categoryMenusLength: column.categoryMenus?.length || 0, }); const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [ columnSetting, ]); if (response.data.success) { console.log("✅ 컬럼 설정 저장 성공"); // 🆕 Category 타입인 경우 컬럼 매핑 처리 console.log("🔍 카테고리 조건 체크:", { isCategory: column.inputType === "category", hasCategoryMenus: !!column.categoryMenus, length: column.categoryMenus?.length || 0, }); if (column.inputType === "category" && !column.categoryRef) { // 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { tableName: selectedTable, columnName: column.columnName, }); try { const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName); console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse); } catch (error) { console.error("❌ 기존 매핑 삭제 실패:", error); } // 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) if (column.categoryMenus && column.categoryMenus.length > 0) { console.log("📥 카테고리 메뉴 매핑 시작:", { columnName: column.columnName, categoryMenus: column.categoryMenus, count: column.categoryMenus.length, }); let successCount = 0; let failCount = 0; for (const menuObjid of column.categoryMenus) { try { const mappingResponse = await createColumnMapping({ tableName: selectedTable, logicalColumnName: column.columnName, physicalColumnName: column.columnName, menuObjid, description: `${column.displayName} (메뉴별 카테고리)`, }); if (mappingResponse.success) { successCount++; } else { console.error("❌ 매핑 생성 실패:", mappingResponse); failCount++; } } catch (error) { console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); failCount++; } } if (successCount > 0 && failCount === 0) { toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`); } else if (successCount > 0 && failCount > 0) { toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); } else if (failCount > 0) { toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패."); } } else { toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)"); } } else { toast.success("컬럼 설정이 성공적으로 저장되었습니다."); } // 원본 데이터 업데이트 setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col))); // 저장 후 데이터 확인을 위해 다시 로드 setTimeout(() => { loadColumnTypes(selectedTable); }, 1000); } else { showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요.", }); } } catch (error) { // console.error("컬럼 설정 저장 실패:", error); showErrorToast("컬럼 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요.", }); } }; // 전체 저장 (테이블 라벨 + 모든 컬럼 설정) const saveAllSettings = async () => { if (!selectedTable) return; if (isSaving) return; // 저장 중 중복 실행 방지 setIsSaving(true); try { // 1. 테이블 라벨 저장 (변경된 경우에만) if (tableLabel !== selectedTable || tableDescription) { try { await apiClient.put(`/table-management/tables/${selectedTable}/label`, { displayName: tableLabel, description: tableDescription, }); } catch (error) { // console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error); } } // 2. 모든 컬럼 설정 저장 if (columns.length > 0) { const columnSettings = columns.map((column) => { // detailSettings 계산 let finalDetailSettings = column.detailSettings || ""; // 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함 if (column.inputType === "entity" && column.referenceTable) { let existingSettings: Record = {}; if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { try { existingSettings = JSON.parse(finalDetailSettings); } catch { existingSettings = {}; } } const entitySettings = { ...existingSettings, entityTable: column.referenceTable, entityCodeColumn: column.referenceColumn || "id", entityLabelColumn: column.displayColumn || "name", }; finalDetailSettings = JSON.stringify(entitySettings); } // 🆕 Code 타입인 경우 hierarchyRole을 detailSettings에 포함 if (column.inputType === "code" && column.hierarchyRole) { let existingSettings: Record = {}; if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { try { existingSettings = JSON.parse(finalDetailSettings); } catch { existingSettings = {}; } } const codeSettings = { ...existingSettings, hierarchyRole: column.hierarchyRole, }; finalDetailSettings = JSON.stringify(codeSettings); } return { columnName: column.columnName, columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, description: column.description || "", codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", displayColumn: column.displayColumn || "", categoryRef: column.categoryRef || null, }; }); // console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings }); // 전체 테이블 설정을 한 번에 저장 const response = await apiClient.post( `/table-management/tables/${selectedTable}/columns/settings`, columnSettings, ); if (response.data.success) { // 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); console.log("📥 전체 저장: 카테고리 컬럼 확인", { totalColumns: columns.length, categoryColumns: categoryColumns.length, categoryColumnsData: categoryColumns.map((col) => ({ columnName: col.columnName, categoryMenus: col.categoryMenus, })), }); if (categoryColumns.length > 0) { let totalSuccessCount = 0; let totalFailCount = 0; for (const column of categoryColumns) { // 1. 먼저 기존 매핑 모두 삭제 console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", { tableName: selectedTable, columnName: column.columnName, }); try { const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName); console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse); } catch (error) { console.error("❌ 기존 매핑 삭제 실패:", error); } // 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) if (column.categoryMenus && column.categoryMenus.length > 0) { for (const menuObjid of column.categoryMenus) { try { console.log("🔄 매핑 API 호출:", { tableName: selectedTable, columnName: column.columnName, menuObjid, }); const mappingResponse = await createColumnMapping({ tableName: selectedTable, logicalColumnName: column.columnName, physicalColumnName: column.columnName, menuObjid, description: `${column.displayName} (메뉴별 카테고리)`, }); console.log("✅ 매핑 API 응답:", mappingResponse); if (mappingResponse.success) { totalSuccessCount++; } else { console.error("❌ 매핑 생성 실패:", mappingResponse); totalFailCount++; } } catch (error) { console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); totalFailCount++; } } } } console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount }); if (totalSuccessCount > 0) { toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`); } else if (totalFailCount > 0) { toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`); } else { toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`); } } else { toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`); } // 저장 성공 후 원본 데이터 업데이트 setOriginalColumns([...columns]); // 테이블 목록 새로고침 (라벨 변경 반영) loadTables(); // 저장 후 데이터 다시 로드 setTimeout(() => { loadColumnTypes(selectedTable, 1, pageSize); }, 1000); } else { showErrorToast("설정 저장에 실패했습니다", response.data.message, { guidance: "잠시 후 다시 시도해 주세요.", }); } } } catch (error) { // console.error("설정 저장 실패:", error); showErrorToast("설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요.", }); } finally { setIsSaving(false); } }; // Ctrl+S 단축키: 테이블 설정 전체 저장 // saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지 const saveAllSettingsRef = useRef(saveAllSettings); saveAllSettingsRef.current = saveAllSettings; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); // 브라우저 기본 저장 동작 방지 if (selectedTable && columns.length > 0) { saveAllSettingsRef.current(); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedTable, columns.length]); // 필터링 + 한글 우선 정렬 (ㄱ~ㅎ → a~z) const filteredTables = useMemo(() => { const filtered = tables.filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || table.displayName.toLowerCase().includes(searchTerm.toLowerCase()), ); const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str); return filtered.sort((a, b) => { const nameA = a.displayName || a.tableName; const nameB = b.displayName || b.tableName; const aKo = isKorean(nameA); const bKo = isKorean(nameB); if (aKo && !bKo) return -1; if (!aKo && bKo) return 1; return nameA.localeCompare(nameB, aKo ? "ko" : "en"); }); }, [tables, searchTerm]); // 선택된 테이블 정보 const selectedTableInfo = tables.find((table) => table.tableName === selectedTable); useEffect(() => { loadTables(); loadCommonCodeCategories(); loadSecondLevelMenus(); loadNumberingRules(); }, []); // 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드 useEffect(() => { if (columns.length > 0) { const entityColumns = columns.filter( (col) => col.inputType === "entity" && col.referenceTable && col.referenceTable !== "none", ); entityColumns.forEach((col) => { if (col.referenceTable) { // console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`); loadReferenceTableColumns(col.referenceTable); } }); } }, [columns, loadReferenceTableColumns]); // 더 많은 데이터 로드 const loadMoreColumns = useCallback(() => { if (selectedTable && columns.length < totalColumns && !columnsLoading) { const nextPage = Math.floor(columns.length / pageSize) + 1; loadColumnTypes(selectedTable, nextPage, pageSize); } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); // PK 체크박스 변경 핸들러 const handlePkToggle = useCallback( (columnName: string, checked: boolean) => { const currentPkCols = [...constraints.primaryKey.columns]; let newPkCols: string[]; if (checked) { newPkCols = [...currentPkCols, columnName]; } else { newPkCols = currentPkCols.filter((c) => c !== columnName); } // PK 변경은 확인 다이얼로그 표시 setPendingPkColumns(newPkCols); setPkDialogOpen(true); }, [constraints.primaryKey.columns], ); // PK 변경 확인 const handlePkConfirm = async () => { if (!selectedTable) return; try { if (pendingPkColumns.length === 0) { toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다."); setPkDialogOpen(false); return; } const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, { columns: pendingPkColumns, }); if (response.data.success) { toast.success(response.data.message); await loadConstraints(selectedTable); } else { toast.error(response.data.message || "PK 설정 실패"); } } catch (error: any) { showErrorToast("PK 설정에 실패했습니다", error, { guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.", }); } finally { setPkDialogOpen(false); } }; // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) const handleIndexToggle = useCallback( async (columnName: string, indexType: "index", checked: boolean) => { if (!selectedTable) return; const action = checked ? "create" : "drop"; try { const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, { columnName, indexType, action, }); if (response.data.success) { toast.success(response.data.message); await loadConstraints(selectedTable); } else { toast.error(response.data.message || "인덱스 설정 실패"); } } catch (error: any) { showErrorToast("인덱스 설정에 실패했습니다", error, { guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.", }); } }, [selectedTable, loadConstraints], ); // 컬럼별 인덱스 상태 헬퍼 const getColumnIndexState = useCallback( (columnName: string) => { const isPk = constraints.primaryKey.columns.includes(columnName); const hasIndex = constraints.indexes.some( (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, ); return { isPk, hasIndex }; }, [constraints], ); // UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴) const handleUniqueToggle = useCallback( async (columnName: string, currentIsUnique: string) => { if (!selectedTable) return; const isCurrentlyUnique = currentIsUnique === "YES"; const newUnique = !isCurrentlyUnique; try { const response = await apiClient.put( `/table-management/tables/${selectedTable}/columns/${columnName}/unique`, { unique: newUnique }, ); if (response.data.success) { toast.success(response.data.message); setColumns((prev) => prev.map((col) => col.columnName === columnName ? { ...col, isUnique: newUnique ? "YES" : "NO" } : col, ), ); } else { showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", response.data.message, { guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.", }); } } catch (error: any) { showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", error, { guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.", }); } }, [selectedTable], ); // NOT NULL 토글 핸들러 const handleNullableToggle = useCallback( async (columnName: string, currentIsNullable: string) => { if (!selectedTable) return; // isNullable이 "YES"면 nullable, "NO"면 NOT NULL // 체크박스 체크 = NOT NULL 설정 (nullable: false) // 체크박스 해제 = NOT NULL 해제 (nullable: true) const isCurrentlyNotNull = currentIsNullable === "NO"; const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정 try { const response = await apiClient.put( `/table-management/tables/${selectedTable}/columns/${columnName}/nullable`, { nullable: newNullable }, ); if (response.data.success) { toast.success(response.data.message); // 컬럼 상태 로컬 업데이트 setColumns((prev) => prev.map((col) => col.columnName === columnName ? { ...col, isNullable: newNullable ? "YES" : "NO" } : col, ), ); } else { showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", response.data.message, { guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.", }); } } catch (error: any) { showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", error, { guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.", }); } }, [selectedTable], ); // 테이블 삭제 확인 const handleDeleteTableClick = (tableName: string) => { setTableToDelete(tableName); setDeleteDialogOpen(true); }; // 테이블 삭제 실행 const handleDeleteTable = async () => { if (!tableToDelete) return; setIsDeleting(true); try { const result = await ddlApi.dropTable(tableToDelete); if (result.success) { toast.success(`테이블 '${tableToDelete}'이 성공적으로 삭제되었습니다.`); // 삭제된 테이블이 선택된 테이블이었다면 선택 해제 if (selectedTable === tableToDelete) { setSelectedTable(null); setColumns([]); } // 테이블 목록 새로고침 await loadTables(); } else { showErrorToast("테이블 삭제에 실패했습니다", result.message, { guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.", }); } } catch (error: any) { showErrorToast("테이블 삭제에 실패했습니다", error, { guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.", }); } finally { setIsDeleting(false); setDeleteDialogOpen(false); setTableToDelete(""); } }; // 체크박스 선택 핸들러 const handleTableCheck = (tableName: string, checked: boolean) => { setSelectedTableIds((prev) => { const newSet = new Set(prev); if (checked) { newSet.add(tableName); } else { newSet.delete(tableName); } return newSet; }); }; // 전체 선택/해제 const handleSelectAll = (checked: boolean) => { if (checked) { const filteredTables = tables.filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), ); setSelectedTableIds(new Set(filteredTables.map((table) => table.tableName))); } else { setSelectedTableIds(new Set()); } }; // 일괄 삭제 확인 const handleBulkDeleteClick = () => { if (selectedTableIds.size === 0) return; setDeleteDialogOpen(true); }; // 일괄 삭제 실행 const handleBulkDelete = async () => { if (selectedTableIds.size === 0) return; setIsDeleting(true); try { const tablesToDelete = Array.from(selectedTableIds); let successCount = 0; let failCount = 0; for (const tableName of tablesToDelete) { try { const result = await ddlApi.dropTable(tableName); if (result.success) { successCount++; // 삭제된 테이블이 선택된 테이블이었다면 선택 해제 if (selectedTable === tableName) { setSelectedTable(null); setColumns([]); } } else { failCount++; } } catch (error) { failCount++; } } if (successCount > 0) { toast.success(`${successCount}개의 테이블이 성공적으로 삭제되었습니다.`); } if (failCount > 0) { toast.error(`${failCount}개의 테이블 삭제에 실패했습니다.`); } // 선택 초기화 및 테이블 목록 새로고침 setSelectedTableIds(new Set()); await loadTables(); } catch (error: any) { showErrorToast("테이블 삭제에 실패했습니다", error, { guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.", }); } finally { setIsDeleting(false); setDeleteDialogOpen(false); setTableToDelete(""); } }; return (
{/* 컴팩트 탑바 (52px) */}

{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}

{tables.length} 테이블
{isSuperAdmin && ( <> {selectedTable && ( )} )}
{/* 3패널 메인 */}
{/* 좌측: 테이블 목록 (240px) */}
{/* 검색 */}
setSearchTerm(e.target.value)} className="bg-background h-[34px] pl-8 text-xs" />
{isSuperAdmin && (
0 && filteredTables.every((table) => selectedTableIds.has(table.tableName)) } onCheckedChange={handleSelectAll} aria-label="전체 선택" className="h-3.5 w-3.5" /> {selectedTableIds.size > 0 ? `${selectedTableIds.size}개` : "전체"}
{selectedTableIds.size > 0 && ( )}
)}
{/* 테이블 리스트 */}
{loading ? (
) : filteredTables.length === 0 ? (
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
) : ( filteredTables.map((table, idx) => { const isActive = selectedTable === table.tableName; const prevTable = idx > 0 ? filteredTables[idx - 1] : null; const isKo = /^[가-힣ㄱ-ㅎ]/.test(table.displayName || table.tableName); const prevIsKo = prevTable ? /^[가-힣ㄱ-ㅎ]/.test(prevTable.displayName || prevTable.tableName) : null; const showDivider = idx === 0 || (prevIsKo !== null && isKo !== prevIsKo); return (
{showDivider && (
{isKo ? "한글" : "ENGLISH"}
)}
handleTableSelect(table.tableName)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleTableSelect(table.tableName); } }} > {isActive && (
)} {isSuperAdmin && ( handleTableCheck(table.tableName, checked as boolean)} aria-label={`${table.displayName || table.tableName} 선택`} className="h-3.5 w-3.5 flex-shrink-0" onClick={(e) => e.stopPropagation()} /> )}
{table.displayName || table.tableName}
{table.tableName}
{table.columnCount}
); }) )}
{/* 하단 정보 */}
{filteredTables.length} / {tables.length} 테이블
{/* 중앙: 컬럼 그리드 */}
{!selectedTable ? (

{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}

) : ( <> {/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
{tableLabel || selectedTable}
{selectedTable}
setTableLabel(e.target.value)} placeholder="표시명" className="h-8 max-w-[160px] text-xs" /> setTableDescription(e.target.value)} placeholder="설명" className="h-8 max-w-[200px] text-xs" />
{columnsLoading ? (
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
) : columns.length === 0 ? (
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
) : ( <> { const idx = columns.findIndex((c) => c.columnName === columnName); if (idx >= 0) handleColumnChange(idx, field, value); }} constraints={constraints} typeFilter={typeFilter} getColumnIndexState={getColumnIndexState} onPkToggle={handlePkToggle} onIndexToggle={(columnName, checked) => handleIndexToggle(columnName, "index", checked) } tables={tables} referenceTableColumns={referenceTableColumns} /> )} )}
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */} {selectedColumn && (
c.columnName === selectedColumn) ?? null} tables={tables} referenceTableColumns={referenceTableColumns} secondLevelMenus={secondLevelMenus} numberingRules={numberingRules} onColumnChange={(field, value) => { if (!selectedColumn) return; if (field === "inputType") { handleInputTypeChange(selectedColumn, value as string); return; } if (field === "referenceTable" && value) { loadReferenceTableColumns(value as string); } setColumns((prev) => prev.map((c) => c.columnName === selectedColumn ? { ...c, [field]: value } : c, ), ); }} onClose={() => setSelectedColumn(null)} onLoadReferenceColumns={loadReferenceTableColumns} codeCategoryOptions={commonCodeOptions} referenceTableOptions={referenceTableOptions} />
)}
{/* DDL 모달 컴포넌트들 */} {isSuperAdmin && ( <> { setCreateTableModalOpen(false); setDuplicateModalMode("create"); setDuplicateSourceTable(null); }} onSuccess={async (result) => { const message = duplicateModalMode === "duplicate" ? "테이블이 성공적으로 복제되었습니다!" : "테이블이 성공적으로 생성되었습니다!"; toast.success(message); // 테이블 목록 새로고침 await loadTables(); // 새로 생성된 테이블 자동 선택 및 컬럼 로드 if (result.data?.tableName) { setSelectedTable(result.data.tableName); setCurrentPage(1); setColumns([]); await loadColumnTypes(result.data.tableName, 1, pageSize); } // 선택 초기화 setSelectedTableIds(new Set()); // 상태 초기화 setDuplicateModalMode("create"); setDuplicateSourceTable(null); }} mode={duplicateModalMode} sourceTableName={duplicateSourceTable || undefined} /> setAddColumnModalOpen(false)} tableName={selectedTable || ""} onSuccess={async (result) => { toast.success("컬럼이 성공적으로 추가되었습니다!"); // 테이블 목록 새로고침 (컬럼 수 업데이트) await loadTables(); // 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋 if (selectedTable) { setCurrentPage(1); setColumns([]); // 기존 컬럼 목록 초기화 await loadColumnTypes(selectedTable, 1, pageSize); } }} /> setDdlLogViewerOpen(false)} /> {/* 테이블 로그 뷰어 */} {/* 테이블 삭제 확인 다이얼로그 */} {selectedTableIds.size > 0 ? "테이블 일괄 삭제 확인" : "테이블 삭제 확인"} {selectedTableIds.size > 0 ? ( <> 선택된 {selectedTableIds.size}개의 테이블을 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다. ) : ( <>정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. )}
{selectedTableIds.size === 0 && tableToDelete && (

경고

테이블 {tableToDelete}과 모든 데이터가 영구적으로 삭제됩니다.

)} {selectedTableIds.size > 0 && (

경고

다음 테이블들과 모든 데이터가 영구적으로 삭제됩니다:

    {Array.from(selectedTableIds).map((tableName) => (
  • {tableName}
  • ))}
)}
)} {/* PK 변경 확인 다이얼로그 */} PK 변경 확인 PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다.
데이터 무결성에 영향을 줄 수 있습니다.

변경될 PK 컬럼:

{pendingPkColumns.length > 0 ? (
{pendingPkColumns.map((col) => { const colInfo = columns.find((c) => c.columnName === col); return ( {colInfo?.displayName && colInfo.displayName !== col ? `${colInfo.displayName} (${col})` : col} ); })}
) : (

PK가 모두 제거됩니다

)}
{/* Scroll to Top 버튼 */}
); }