ERP-node/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx

1829 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-[240px] min-w-[240px] 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-[12px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.displayName || table.tableName}
</span>
</div>
<div className="text-muted-foreground truncate font-mono text-[10px] 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}
/>
</>
)}
</>
)}
</div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[320px] min-w-[320px] 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>
);
}