1609 lines
71 KiB
TypeScript
1609 lines
71 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, useCallback } 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 { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react";
|
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
|
import { toast } from "sonner";
|
|
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 { 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";
|
|
|
|
interface TableInfo {
|
|
tableName: string;
|
|
displayName: string;
|
|
description: string;
|
|
columnCount: number;
|
|
}
|
|
|
|
interface ColumnTypeInfo {
|
|
columnName: string;
|
|
displayName: string;
|
|
inputType: string; // webType → inputType 변경
|
|
detailSettings: string;
|
|
description: string;
|
|
isNullable: string;
|
|
defaultValue?: string;
|
|
maxLength?: number;
|
|
numericPrecision?: number;
|
|
numericScale?: number;
|
|
codeCategory?: string;
|
|
codeValue?: string;
|
|
referenceTable?: string;
|
|
referenceColumn?: string;
|
|
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
|
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
|
}
|
|
|
|
interface SecondLevelMenu {
|
|
menuObjid: number;
|
|
menuName: string;
|
|
parentMenuName: string;
|
|
screenCode?: string;
|
|
}
|
|
|
|
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[]>>({});
|
|
|
|
// 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[]>([]);
|
|
|
|
// 로그 뷰어 상태
|
|
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
|
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
|
|
|
// 테이블 삭제 확인 다이얼로그 상태
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [tableToDelete, setTableToDelete] = useState<string>("");
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// 선택된 테이블 목록 (체크박스)
|
|
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
|
|
|
|
// 최고 관리자 여부 확인 (회사코드가 "*" 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 loadTables = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get("/table-management/tables");
|
|
|
|
// 응답 상태 확인
|
|
if (response.data.success) {
|
|
setTables(response.data.data);
|
|
toast.success("테이블 목록을 성공적으로 로드했습니다.");
|
|
} else {
|
|
toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
// console.error("테이블 목록 로드 실패:", error);
|
|
toast.error("테이블 목록 로드 중 오류가 발생했습니다.");
|
|
} 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) => ({
|
|
...col,
|
|
inputType: col.inputType || "text", // 기본값: text
|
|
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
|
}));
|
|
|
|
if (page === 1) {
|
|
setColumns(processedColumns);
|
|
setOriginalColumns(processedColumns);
|
|
} else {
|
|
// 페이지 추가 로드 시 기존 데이터에 추가
|
|
setColumns((prev) => [...prev, ...processedColumns]);
|
|
}
|
|
setTotalColumns(data.total || processedColumns.length);
|
|
toast.success("컬럼 정보를 성공적으로 로드했습니다.");
|
|
} else {
|
|
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
// console.error("컬럼 타입 정보 로드 실패:", error);
|
|
toast.error("컬럼 정보 로드 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setColumnsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 테이블 선택
|
|
const handleTableSelect = useCallback(
|
|
(tableName: string) => {
|
|
setSelectedTable(tableName);
|
|
setCurrentPage(1);
|
|
setColumns([]);
|
|
|
|
// 선택된 테이블 정보에서 라벨 설정
|
|
const tableInfo = tables.find((table) => table.tableName === tableName);
|
|
setTableLabel(tableInfo?.displayName || tableName);
|
|
setTableDescription(tableInfo?.description || "");
|
|
|
|
loadColumnTypes(tableName, 1, pageSize);
|
|
},
|
|
[loadColumnTypes, 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);
|
|
return {
|
|
...col,
|
|
inputType: newInputType,
|
|
detailSettings: inputTypeOption?.description || col.detailSettings,
|
|
};
|
|
}
|
|
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;
|
|
|
|
if (settingType === "code") {
|
|
if (value === "none") {
|
|
newDetailSettings = "";
|
|
codeCategory = undefined;
|
|
codeValue = undefined;
|
|
} else {
|
|
const codeOption = commonCodeOptions.find((option) => option.value === value);
|
|
newDetailSettings = codeOption ? `공통코드: ${codeOption.label}` : "";
|
|
codeCategory = value;
|
|
codeValue = value;
|
|
}
|
|
} 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,
|
|
};
|
|
}
|
|
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 {
|
|
const columnSetting = {
|
|
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
|
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
|
inputType: column.inputType || "text",
|
|
detailSettings: column.detailSettings || "",
|
|
codeCategory: column.codeCategory || "",
|
|
codeValue: column.codeValue || "",
|
|
referenceTable: column.referenceTable || "",
|
|
referenceColumn: column.referenceColumn || "",
|
|
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
|
};
|
|
|
|
// 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") {
|
|
// 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) {
|
|
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 {
|
|
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
// console.error("컬럼 설정 저장 실패:", error);
|
|
toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
|
|
const saveAllSettings = async () => {
|
|
if (!selectedTable) return;
|
|
|
|
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) => ({
|
|
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
|
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
|
inputType: column.inputType || "text",
|
|
detailSettings: column.detailSettings || "",
|
|
description: column.description || "",
|
|
codeCategory: column.codeCategory || "",
|
|
codeValue: column.codeValue || "",
|
|
referenceTable: column.referenceTable || "",
|
|
referenceColumn: column.referenceColumn || "",
|
|
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
|
}));
|
|
|
|
// console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
|
|
|
|
// 전체 테이블 설정을 한 번에 저장
|
|
const response = await apiClient.post(
|
|
`/table-management/tables/${selectedTable}/columns/settings`,
|
|
columnSettings,
|
|
);
|
|
|
|
if (response.data.success) {
|
|
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
|
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
|
|
|
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 {
|
|
toast.error(response.data.message || "설정 저장에 실패했습니다.");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// console.error("설정 저장 실패:", error);
|
|
toast.error("설정 저장 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 필터링된 테이블 목록 (메모이제이션)
|
|
const filteredTables = useMemo(
|
|
() =>
|
|
tables.filter(
|
|
(table) =>
|
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
),
|
|
[tables, searchTerm],
|
|
);
|
|
|
|
// 선택된 테이블 정보
|
|
const selectedTableInfo = tables.find((table) => table.tableName === selectedTable);
|
|
|
|
useEffect(() => {
|
|
loadTables();
|
|
loadCommonCodeCategories();
|
|
loadSecondLevelMenus();
|
|
}, []);
|
|
|
|
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
|
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]);
|
|
|
|
// 테이블 삭제 확인
|
|
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 {
|
|
toast.error(result.message || "테이블 삭제에 실패했습니다.");
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error?.response?.data?.message || "테이블 삭제 중 오류가 발생했습니다.");
|
|
} 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) {
|
|
toast.error("테이블 삭제 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsDeleting(false);
|
|
setDeleteDialogOpen(false);
|
|
setTableToDelete("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background flex h-screen flex-col">
|
|
<div className="flex h-full flex-col space-y-6 overflow-hidden p-6">
|
|
{/* 페이지 헤더 */}
|
|
<div className="flex-shrink-0 space-y-2 border-b pb-4">
|
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">
|
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
|
</h1>
|
|
<p className="text-muted-foreground mt-2 text-sm">
|
|
{getTextFromUI(
|
|
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
|
|
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
|
|
)}
|
|
</p>
|
|
{isSuperAdmin && (
|
|
<p className="text-primary mt-1 text-sm font-medium">
|
|
최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* DDL 기능 버튼들 (최고 관리자만) */}
|
|
{isSuperAdmin && (
|
|
<>
|
|
<Button
|
|
onClick={() => {
|
|
setDuplicateModalMode("create");
|
|
setDuplicateSourceTable(null);
|
|
setCreateTableModalOpen(true);
|
|
}}
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
size="default"
|
|
>
|
|
<Plus className="h-4 w-4" />새 테이블 생성
|
|
</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"
|
|
disabled={selectedTableIds.size !== 1}
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
테이블 복제
|
|
</Button>
|
|
|
|
{selectedTable && (
|
|
<Button
|
|
onClick={() => setAddColumnModalOpen(true)}
|
|
variant="outline"
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
컬럼 추가
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
onClick={() => setDdlLogViewerOpen(true)}
|
|
variant="outline"
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
>
|
|
<Activity className="h-4 w-4" />
|
|
DDL 로그
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
onClick={loadTables}
|
|
disabled={loading}
|
|
variant="outline"
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex h-full flex-1 gap-6 overflow-hidden">
|
|
{/* 좌측 사이드바: 테이블 목록 (20%) */}
|
|
<div className="flex h-full w-[20%] flex-col border-r pr-4">
|
|
<div className="flex h-full flex-col space-y-4">
|
|
{/* 검색 */}
|
|
<div className="flex-shrink-0">
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="h-10 pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 목록 */}
|
|
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
{/* 전체 선택 및 일괄 삭제 (최고 관리자만) */}
|
|
{isSuperAdmin && (
|
|
<div className="flex items-center justify-between border-b pb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={
|
|
tables.filter(
|
|
(table) =>
|
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
).length > 0 &&
|
|
tables
|
|
.filter(
|
|
(table) =>
|
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
)
|
|
.every((table) => selectedTableIds.has(table.tableName))
|
|
}
|
|
onCheckedChange={handleSelectAll}
|
|
aria-label="전체 선택"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">
|
|
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
|
|
</span>
|
|
</div>
|
|
{selectedTableIds.size > 0 && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={handleBulkDeleteClick}
|
|
className="h-8 gap-2 text-xs"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
삭제
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<LoadingSpinner />
|
|
<span className="text-muted-foreground ml-2 text-sm">
|
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
|
|
</span>
|
|
</div>
|
|
) : tables.length === 0 ? (
|
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
|
</div>
|
|
) : (
|
|
tables
|
|
.filter(
|
|
(table) =>
|
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
)
|
|
.map((table) => (
|
|
<div
|
|
key={table.tableName}
|
|
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
|
|
selectedTable === table.tableName
|
|
? "shadow-md bg-muted/30"
|
|
: "hover:shadow-lg hover:bg-muted/20"
|
|
}`}
|
|
style={
|
|
selectedTable === table.tableName
|
|
? { border: "2px solid #000000" }
|
|
: { border: "2px solid transparent" }
|
|
}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* 체크박스 (최고 관리자만) */}
|
|
{isSuperAdmin && (
|
|
<Checkbox
|
|
checked={selectedTableIds.has(table.tableName)}
|
|
onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)}
|
|
aria-label={`${table.displayName || table.tableName} 선택`}
|
|
className="mt-0.5"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
)}
|
|
<div
|
|
className="flex-1 cursor-pointer"
|
|
onClick={() => handleTableSelect(table.tableName)}
|
|
>
|
|
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
|
</p>
|
|
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
|
<span className="text-muted-foreground text-xs">컬럼</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{table.columnCount}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
|
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
|
<div className="flex h-full flex-col space-y-4 overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!selectedTable ? (
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 테이블 라벨 설정 */}
|
|
<div className="mb-4 flex items-center gap-4">
|
|
<div className="flex-1">
|
|
<Input
|
|
value={tableLabel}
|
|
onChange={(e) => setTableLabel(e.target.value)}
|
|
placeholder="테이블 표시명"
|
|
className="h-10 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<Input
|
|
value={tableDescription}
|
|
onChange={(e) => setTableDescription(e.target.value)}
|
|
placeholder="테이블 설명"
|
|
className="h-10 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{columnsLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<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 py-8 text-center text-sm">
|
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* 컬럼 헤더 */}
|
|
<div className="text-foreground grid h-12 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
|
<div className="pr-4">컬럼명</div>
|
|
<div className="px-4">라벨</div>
|
|
<div className="pr-6">입력 타입</div>
|
|
<div className="pl-4">설명</div>
|
|
</div>
|
|
|
|
{/* 컬럼 리스트 */}
|
|
<div
|
|
className="max-h-96 overflow-y-auto"
|
|
onScroll={(e) => {
|
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
|
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
|
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
|
loadMoreColumns();
|
|
}
|
|
}}
|
|
>
|
|
{columns.map((column, index) => (
|
|
<div
|
|
key={column.columnName}
|
|
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
|
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
|
>
|
|
<div className="pr-4 pt-1">
|
|
<div className="font-mono text-sm">{column.columnName}</div>
|
|
</div>
|
|
<div className="px-4">
|
|
<Input
|
|
value={column.displayName || ""}
|
|
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
|
placeholder={column.columnName}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="pr-6">
|
|
<div className="space-y-3">
|
|
{/* 입력 타입 선택 */}
|
|
<Select
|
|
value={column.inputType || "text"}
|
|
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="입력 타입 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{memoizedInputTypeOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
|
{column.inputType === "code" && (
|
|
<Select
|
|
value={column.codeCategory || "none"}
|
|
onValueChange={(value) =>
|
|
handleDetailSettingsChange(column.columnName, "code", value)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="공통코드 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{commonCodeOptions.map((option, index) => (
|
|
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
|
{column.inputType === "category" && (
|
|
<div className="space-y-2">
|
|
<label className="text-muted-foreground mb-1 block text-xs">
|
|
적용할 메뉴 (2레벨)
|
|
</label>
|
|
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
|
{secondLevelMenus.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
|
</p>
|
|
) : (
|
|
secondLevelMenus.map((menu) => {
|
|
// menuObjid를 숫자로 변환하여 비교
|
|
const menuObjidNum = Number(menu.menuObjid);
|
|
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
|
|
|
return (
|
|
<div key={menu.menuObjid} className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
|
checked={isChecked}
|
|
onChange={(e) => {
|
|
const currentMenus = column.categoryMenus || [];
|
|
const newMenus = e.target.checked
|
|
? [...currentMenus, menuObjidNum]
|
|
: currentMenus.filter((id) => id !== menuObjidNum);
|
|
|
|
setColumns((prev) =>
|
|
prev.map((col) =>
|
|
col.columnName === column.columnName
|
|
? { ...col, categoryMenus: newMenus }
|
|
: col
|
|
)
|
|
);
|
|
}}
|
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<label
|
|
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
|
className="text-xs cursor-pointer flex-1"
|
|
>
|
|
{menu.parentMenuName} → {menu.menuName}
|
|
</label>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
|
<p className="text-primary text-xs">
|
|
{column.categoryMenus.length}개 메뉴 선택됨
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
|
{column.inputType === "entity" && (
|
|
<>
|
|
{/* 참조 테이블 */}
|
|
<div className="w-48">
|
|
<label className="text-muted-foreground mb-1 block text-xs">
|
|
참조 테이블
|
|
</label>
|
|
<Select
|
|
value={column.referenceTable || "none"}
|
|
onValueChange={(value) =>
|
|
handleDetailSettingsChange(column.columnName, "entity", value)
|
|
}
|
|
>
|
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{referenceTableOptions.map((option, index) => (
|
|
<SelectItem
|
|
key={`entity-${option.value}-${index}`}
|
|
value={option.value}
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{option.label}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{option.value}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 조인 컬럼 */}
|
|
{column.referenceTable && column.referenceTable !== "none" && (
|
|
<div className="w-48">
|
|
<label className="text-muted-foreground mb-1 block text-xs">
|
|
조인 컬럼
|
|
</label>
|
|
<Select
|
|
value={column.referenceColumn || "none"}
|
|
onValueChange={(value) =>
|
|
handleDetailSettingsChange(
|
|
column.columnName,
|
|
"entity_reference_column",
|
|
value,
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
|
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
|
<SelectItem
|
|
key={`ref-col-${refCol.columnName}-${index}`}
|
|
value={refCol.columnName}
|
|
>
|
|
<span className="font-medium">{refCol.columnName}</span>
|
|
</SelectItem>
|
|
))}
|
|
{(!referenceTableColumns[column.referenceTable] ||
|
|
referenceTableColumns[column.referenceTable].length === 0) && (
|
|
<SelectItem value="loading" disabled>
|
|
<div className="flex items-center gap-2">
|
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
|
로딩중
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 표시 컬럼 */}
|
|
{column.referenceTable &&
|
|
column.referenceTable !== "none" &&
|
|
column.referenceColumn &&
|
|
column.referenceColumn !== "none" && (
|
|
<div className="w-48">
|
|
<label className="text-muted-foreground mb-1 block text-xs">
|
|
표시 컬럼
|
|
</label>
|
|
<Select
|
|
value={column.displayColumn || "none"}
|
|
onValueChange={(value) =>
|
|
handleDetailSettingsChange(
|
|
column.columnName,
|
|
"entity_display_column",
|
|
value,
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
|
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
|
<SelectItem
|
|
key={`ref-col-${refCol.columnName}-${index}`}
|
|
value={refCol.columnName}
|
|
>
|
|
<span className="font-medium">{refCol.columnName}</span>
|
|
</SelectItem>
|
|
))}
|
|
{(!referenceTableColumns[column.referenceTable] ||
|
|
referenceTableColumns[column.referenceTable].length === 0) && (
|
|
<SelectItem value="loading" disabled>
|
|
<div className="flex items-center gap-2">
|
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
|
로딩중
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 설정 완료 표시 */}
|
|
{column.referenceTable &&
|
|
column.referenceTable !== "none" &&
|
|
column.referenceColumn &&
|
|
column.referenceColumn !== "none" &&
|
|
column.displayColumn &&
|
|
column.displayColumn !== "none" && (
|
|
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
|
<span>✓</span>
|
|
<span className="truncate">설정 완료</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="pl-4">
|
|
<Input
|
|
value={column.description || ""}
|
|
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
|
placeholder="설명"
|
|
className="h-8 w-full text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 로딩 표시 */}
|
|
{columnsLoading && (
|
|
<div className="flex items-center justify-center py-4">
|
|
<LoadingSpinner />
|
|
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 페이지 정보 */}
|
|
<div className="text-muted-foreground text-center text-sm">
|
|
{columns.length} / {totalColumns} 컬럼 표시됨
|
|
</div>
|
|
|
|
{/* 전체 저장 버튼 */}
|
|
<div className="flex justify-end pt-4">
|
|
<Button
|
|
onClick={saveAllSettings}
|
|
disabled={!selectedTable || columns.length === 0}
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
전체 설정 저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</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>
|
|
</>
|
|
)}
|
|
|
|
{/* Scroll to Top 버튼 */}
|
|
<ScrollToTop />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|