Files
pipeline/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx
T
kjs 764e0ae568 style: adjust panel and text sizes in Table Management and Column Detail components
- Increased the width of the left panel in the Table Management page from 240px to 280px for better visibility.
- Updated text sizes in the Table Management page for table names and descriptions to enhance readability.
- Expanded the width of the right panel in the Table Management page from 320px to 380px to accommodate additional content.
- Adjusted text sizes in the Column Detail Panel for improved clarity and consistency.

These changes aim to enhance the user interface and improve the overall user experience in the table management section.
2026-03-17 16:20:07 +09:00

1833 lines
71 KiB
TypeScript

"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<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
const [columnsLoading, setColumnsLoading] = useState(false);
const [originalColumns, setOriginalColumns] = useState<ColumnTypeInfo[]>([]); // 원본 데이터 저장
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
// 페이지네이션 상태
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<Record<string, ReferenceTableColumn[]>>({});
// 🆕 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<string | null>(null);
// 🆕 Category 타입용: 2레벨 메뉴 목록
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
// 🆕 Numbering 타입용: 채번규칙 목록
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
const [numberingComboboxOpen, setNumberingComboboxOpen] = useState<Record<string, boolean>>({});
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
// 저장 중 상태 (중복 실행 방지)
const [isSaving, setIsSaving] = useState(false);
// 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>("");
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<string[]>([]);
// 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
// 컬럼 그리드: 선택된 컬럼(우측 상세 패널 표시)
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
// 타입 오버뷰 스트립: 타입 필터 (null = 전체)
const [typeFilter, setTypeFilter] = useState<string | null>(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.tableName })),
];
// 공통 코드 카테고리 목록 상태
const [commonCodeCategories, setCommonCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
// 공통 코드 옵션
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<string, any> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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 (
<div className="bg-background flex h-screen flex-col overflow-hidden">
{/* 컴팩트 탑바 (52px) */}
<div className="flex h-[52px] flex-shrink-0 items-center justify-between border-b px-5">
<div className="flex items-center gap-3">
<Database className="h-4.5 w-4.5 text-muted-foreground" />
<h1 className="text-[15px] font-bold tracking-tight">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<Badge variant="secondary" className="text-[10px] font-bold">
{tables.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
{isSuperAdmin && (
<>
<Button
onClick={() => {
setDuplicateModalMode("create");
setDuplicateSourceTable(null);
setCreateTableModalOpen(true);
}}
size="sm"
className="h-8 gap-1.5 text-xs"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
onClick={() => {
if (selectedTableIds.size !== 1) {
toast.error("복제할 테이블을 1개만 선택해주세요.");
return;
}
const sourceTable = Array.from(selectedTableIds)[0];
setDuplicateSourceTable(sourceTable);
setDuplicateModalMode("duplicate");
setCreateTableModalOpen(true);
}}
variant="outline"
size="sm"
disabled={selectedTableIds.size !== 1}
className="h-8 gap-1.5 text-xs"
>
<Copy className="h-3.5 w-3.5" />
</Button>
{selectedTable && (
<Button
onClick={() => setAddColumnModalOpen(true)}
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
<Button
onClick={() => setDdlLogViewerOpen(true)}
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs"
>
<Activity className="h-3.5 w-3.5" />
DDL
</Button>
</>
)}
<Button
onClick={loadTables}
disabled={loading}
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* 3패널 메인 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
{/* 검색 */}
<div className="flex-shrink-0 p-3 pb-0">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-background h-[34px] pl-8 text-xs"
/>
</div>
{isSuperAdmin && (
<div className="mt-2 flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-1.5">
<Checkbox
checked={
filteredTables.length > 0 &&
filteredTables.every((table) => selectedTableIds.has(table.tableName))
}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
className="h-3.5 w-3.5"
/>
<span className="text-muted-foreground text-[10px]">
{selectedTableIds.size > 0 ? `${selectedTableIds.size}` : "전체"}
</span>
</div>
{selectedTableIds.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDeleteClick}
className="h-6 gap-1 px-2 text-[10px]"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
{/* 테이블 리스트 */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : filteredTables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-xs">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div>
) : (
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 (
<div key={table.tableName}>
{showDivider && (
<div className="text-muted-foreground/60 mt-2 mb-1 px-2 text-[9px] font-bold uppercase tracking-widest">
{isKo ? "한글" : "ENGLISH"}
</div>
)}
<div
className={cn(
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50",
)}
onClick={() => handleTableSelect(table.tableName)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTableSelect(table.tableName);
}
}}
>
{isActive && (
<div className="bg-primary absolute top-1.5 bottom-1.5 left-0 w-[3px] rounded-r" />
)}
{isSuperAdmin && (
<Checkbox
checked={selectedTableIds.has(table.tableName)}
onCheckedChange={(checked) => 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()}
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1">
<span className={cn(
"truncate text-[16px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.displayName || table.tableName}
</span>
</div>
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
{table.tableName}
</div>
</div>
<span className={cn(
"flex-shrink-0 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-bold leading-none",
isActive
? "bg-primary/15 text-primary"
: "text-muted-foreground",
)}>
{table.columnCount}
</span>
</div>
</div>
);
})
)}
</div>
{/* 하단 정보 */}
<div className="text-muted-foreground flex-shrink-0 border-t px-3 py-2 text-[10px] font-medium">
{filteredTables.length} / {tables.length}
</div>
</div>
{/* 중앙: 컬럼 그리드 */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!selectedTable ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<Database className="text-muted-foreground/40 h-10 w-10" />
<p className="text-muted-foreground text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p>
</div>
) : (
<>
{/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
<div className="bg-card flex flex-shrink-0 items-center gap-3 border-b px-5 py-3">
<div className="min-w-0 flex-shrink-0">
<div className="text-[15px] font-bold tracking-tight">
{tableLabel || selectedTable}
</div>
<div className="text-muted-foreground font-mono text-[11px] tracking-tight">
{selectedTable}
</div>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="표시명"
className="h-8 max-w-[160px] text-xs"
/>
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="설명"
className="h-8 max-w-[200px] text-xs"
/>
</div>
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0 || isSaving}
size="sm"
className="h-8 gap-1.5 text-xs"
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{isSaving ? "저장 중..." : "전체 설정 저장"}
</Button>
</div>
{columnsLoading ? (
<div className="flex flex-1 items-center justify-center">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span>
</div>
) : columns.length === 0 ? (
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<>
<TypeOverviewStrip
columns={columns}
activeFilter={typeFilter}
onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn}
onColumnChange={(columnName, field, value) => {
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)
}
/>
</>
)}
</>
)}
</div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
<ColumnDetailPanel
column={columns.find((c) => 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}
/>
</div>
)}
</div>
{/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && (
<>
<CreateTableModal
isOpen={createTableModalOpen}
onClose={() => {
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}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)
await loadTables();
// 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋
if (selectedTable) {
setCurrentPage(1);
setColumns([]); // 기존 컬럼 목록 초기화
await loadColumnTypes(selectedTable, 1, pageSize);
}
}}
/>
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
{/* 테이블 로그 뷰어 */}
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
{/* 테이블 삭제 확인 다이얼로그 */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedTableIds.size > 0 ? "테이블 일괄 삭제 확인" : "테이블 삭제 확인"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedTableIds.size > 0 ? (
<>
<strong>{selectedTableIds.size}</strong> ?
<br /> .
</>
) : (
<> ? .</>
)}
</DialogDescription>
</DialogHeader>
{selectedTableIds.size === 0 && tableToDelete && (
<div className="space-y-3 sm:space-y-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<p className="text-destructive text-sm font-semibold"></p>
<p className="text-destructive/80 mt-1.5 text-sm">
<span className="font-mono font-bold">{tableToDelete}</span>
.
</p>
</div>
</div>
)}
{selectedTableIds.size > 0 && (
<div className="space-y-3 sm:space-y-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<p className="text-destructive text-sm font-semibold"></p>
<p className="text-destructive/80 mt-1.5 text-sm">
:
</p>
<ul className="text-destructive/80 mt-2 list-disc pl-5 text-sm">
{Array.from(selectedTableIds).map((tableName) => (
<li key={tableName} className="font-mono">
{tableName}
</li>
))}
</ul>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setTableToDelete("");
setSelectedTableIds(new Set());
}}
disabled={isDeleting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={selectedTableIds.size > 0 ? handleBulkDelete : handleDeleteTable}
disabled={isDeleting}
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isDeleting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
{/* PK 변경 확인 다이얼로그 */}
<Dialog open={pkDialogOpen} onOpenChange={setPkDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">PK </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
PK를 .
<br /> .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="rounded-lg border p-4">
<p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => (
<Badge key={col} variant="secondary" className="font-mono text-xs">
{col}
</Badge>
))}
</div>
) : (
<p className="text-destructive mt-2 text-sm">PK가 </p>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPkDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handlePkConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}