acbab68a12
1. 운영 console.log/warn 제거 (G15) — page.tsx 의 이모지 prefix 디버그 로그 (🔍 🔄 ✅ 🗑️ 📥 📊 📋) 일괄 제거. catch 블록의 console.error 는 추적용으로 유지. CreateTableModal 의 컬럼 조회 디버그 로그도 정리. 2. useLogTable dead code 정리 (G16) — CreateTableModal 의 useLogTable state, handleCreateTable 분기, 주석 처리된 체크박스 UI 모두 제거. 시그니처 안 맞는 createLogTable 호출 페이로드까지 같이 사라짐. Activity / Checkbox import 도 필요 없어졌으므로 제거. 3. 에러 메시지 일관화 (G17) — DdlController 와 TableManagementController 의 "최고 관리자 권한이 필요합니다." 메시지 모두 "최고 관리자(SUPER_ADMIN) 권한이 필요합니다." 로 통일. 일반 ADMIN 권한 메시지는 그대로 유지. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2071 lines
84 KiB
TypeScript
2071 lines
84 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,
|
|
Pencil,
|
|
Columns3,
|
|
Link2,
|
|
} from "lucide-react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
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, USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { getCodeInfoList } from "@/lib/api/commonCode";
|
|
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
|
import { ddlApi } from "@/lib/api/ddl";
|
|
// getSecondLevelMenus / createColumnMapping / deleteColumnMappingsByColumn (카테고리 모듈 폐기)
|
|
const getSecondLevelMenus = async (): Promise<{ success: boolean; data?: any[] }> => ({ success: true, data: [] });
|
|
const createColumnMapping = async (_params: Record<string, any>): Promise<{ success: boolean }> => ({ success: true });
|
|
const deleteColumnMappingsByColumn = async (_table: string, _column: string): Promise<{ success: boolean }> => ({ success: true });
|
|
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
|
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";
|
|
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
|
|
|
|
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("");
|
|
// 헤더 인라인 편집 상태 (Google Docs / Notion 패턴)
|
|
const [editingHeaderField, setEditingHeaderField] = useState<"label" | "description" | null>(null);
|
|
const [editingHeaderValue, setEditingHeaderValue] = 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[]>([]);
|
|
|
|
// 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요)
|
|
|
|
// 로그 뷰어 상태
|
|
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 [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false);
|
|
const [columnToDelete, setColumnToDelete] = useState<string>("");
|
|
const [isDeletingColumn, setIsDeletingColumn] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// PK/인덱스 관리 상태
|
|
const [constraints, setConstraints] = useState<{
|
|
primaryKey: { name: string; columns: string[] };
|
|
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
|
|
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
|
const [pkDialogOpen, setPkDialogOpen] = useState(false);
|
|
// 이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 매번 다이얼로그 뜨는 답답함 해소)
|
|
const [pkSkipConfirmSession, setPkSkipConfirmSession] = 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?.company_code === "*" && user?.user_type === "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개 사용자 선택 가능 타입 — Layer 2)
|
|
const inputTypeOptions = INPUT_TYPE_OPTIONS
|
|
.filter((o) => USER_SELECTABLE_INPUT_TYPE_ORDER.includes(o.value as any))
|
|
.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.table_name,
|
|
label:
|
|
table.display_name && table.display_name !== table.table_name
|
|
? `${table.display_name} (${table.table_name})`
|
|
: table.table_name,
|
|
})),
|
|
];
|
|
|
|
// 공통 코드 카테고리 목록 상태
|
|
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 getCodeInfoList({ is_active: true });
|
|
|
|
if (response.success && response.data) {
|
|
const categories = response.data.map((row: Record<string, any>) => ({
|
|
value: row.code_info,
|
|
label: row.code_name || row.code_info,
|
|
}));
|
|
|
|
// 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 {
|
|
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
|
|
}
|
|
} catch (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 {
|
|
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;
|
|
|
|
// 컬럼 데이터에 기본값 설정
|
|
const processedColumns = (data.columns || data).map((col: any) => {
|
|
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
|
if (col.detail_settings && typeof col.detail_settings === "string") {
|
|
try {
|
|
const parsed = JSON.parse(col.detail_settings);
|
|
if (
|
|
parsed.hierarchy_role === "large" ||
|
|
parsed.hierarchy_role === "medium" ||
|
|
parsed.hierarchy_role === "small"
|
|
) {
|
|
hierarchyRole = parsed.hierarchy_role;
|
|
}
|
|
} catch {
|
|
// JSON 파싱 실패 시 무시
|
|
}
|
|
}
|
|
|
|
return {
|
|
...col,
|
|
input_type: col.input_type || "text",
|
|
is_unique: col.is_unique || "NO",
|
|
category_menus: col.category_menus || [],
|
|
hierarchy_role: hierarchyRole,
|
|
category_ref: col.category_ref || 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) {
|
|
const data = response.data.data;
|
|
setConstraints({
|
|
primaryKey: data.primary_key ?? { name: "", columns: [] },
|
|
indexes: data.indexes ?? [],
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("제약조건 로드 실패:", error);
|
|
setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
|
}
|
|
}, []);
|
|
|
|
// ESC 키로 우측 상세 패널 닫기 (좁은 화면에서 stuck 방지)
|
|
useEffect(() => {
|
|
if (!selectedColumn) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setSelectedColumn(null);
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [selectedColumn]);
|
|
|
|
// 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존)
|
|
const hasUnsavedChanges = useMemo(() => {
|
|
if (columns.length === 0 || originalColumns.length === 0) return false;
|
|
if (columns.length !== originalColumns.length) return true;
|
|
// 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서)
|
|
try {
|
|
return JSON.stringify(columns) !== JSON.stringify(originalColumns);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, [columns, originalColumns]);
|
|
|
|
// 테이블 선택
|
|
const handleTableSelect = useCallback(
|
|
(tableName: string) => {
|
|
if (tableName === selectedTable) return;
|
|
if (hasUnsavedChanges) {
|
|
const ok = typeof window !== "undefined"
|
|
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
|
|
: true;
|
|
if (!ok) return;
|
|
}
|
|
setSelectedTable(tableName);
|
|
setCurrentPage(1);
|
|
setColumns([]);
|
|
setSelectedColumn(null);
|
|
setTypeFilter(null);
|
|
|
|
// 선택된 테이블 정보에서 라벨 설정
|
|
const tableInfo = tables.find((table) => table.table_name === tableName);
|
|
setTableLabel(tableInfo?.display_name || tableName);
|
|
setTableDescription(tableInfo?.description || "");
|
|
|
|
loadColumnTypes(tableName, 1, pageSize);
|
|
loadConstraints(tableName);
|
|
},
|
|
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
|
|
);
|
|
|
|
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
|
|
const handleInputTypeChange = useCallback(
|
|
(columnName: string, newInputType: string) => {
|
|
// typeFilter 가 활성화된 상태에서 변경된 input_type 이 필터와 불일치하면 자동으로 필터 해제
|
|
// (그렇지 않으면 사용자가 방금 편집한 행이 그리드에서 갑자기 사라져 혼란)
|
|
if (typeFilter && typeFilter !== newInputType) {
|
|
setTypeFilter(null);
|
|
}
|
|
setColumns((prev) =>
|
|
prev.map((col) => {
|
|
if (col.column_name === columnName) {
|
|
const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
|
|
const updated: typeof col = {
|
|
...col,
|
|
input_type: newInputType,
|
|
detail_settings: inputTypeOption?.description || col.detail_settings,
|
|
};
|
|
|
|
// 엔티티가 아닌 타입으로 변경 시 참조 설정 초기화
|
|
if (newInputType !== "entity") {
|
|
updated.reference_table = undefined;
|
|
updated.reference_column = undefined;
|
|
updated.display_column = undefined;
|
|
}
|
|
|
|
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
|
|
if (newInputType !== "code") {
|
|
updated.code_info = undefined;
|
|
updated.code_value = undefined;
|
|
updated.hierarchy_role = undefined;
|
|
}
|
|
|
|
// 카테고리가 아닌 타입으로 변경 시 카테고리 참조 초기화
|
|
if (newInputType !== "category") {
|
|
updated.category_ref = undefined;
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
return col;
|
|
}),
|
|
);
|
|
},
|
|
[memoizedInputTypeOptions],
|
|
);
|
|
|
|
// 상세 설정 변경 (코드/엔티티 타입용)
|
|
const handleDetailSettingsChange = useCallback(
|
|
(columnName: string, settingType: string, value: string) => {
|
|
setColumns((prev) =>
|
|
prev.map((col) => {
|
|
if (col.column_name === columnName) {
|
|
let newDetailSettings = col.detail_settings;
|
|
let codeInfo = col.code_info;
|
|
let codeValue = col.code_value;
|
|
let referenceTable = col.reference_table;
|
|
let referenceColumn = col.reference_column;
|
|
let displayColumn = col.display_column;
|
|
let hierarchyRole = col.hierarchy_role;
|
|
|
|
if (settingType === "code") {
|
|
if (value === "none") {
|
|
newDetailSettings = "";
|
|
codeInfo = undefined;
|
|
codeValue = undefined;
|
|
hierarchyRole = undefined; // 코드 선택 해제 시 계층 역할도 초기화
|
|
} else {
|
|
// 기존 hierarchyRole 유지하면서 JSON 형식으로 저장
|
|
const existingHierarchyRole = hierarchyRole;
|
|
newDetailSettings = JSON.stringify({
|
|
code_info: value,
|
|
hierarchy_role: existingHierarchyRole,
|
|
});
|
|
codeInfo = 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.detail_settings === "string" && col.detail_settings.trim().startsWith("{")) {
|
|
try {
|
|
existingSettings = JSON.parse(col.detail_settings);
|
|
} catch {
|
|
existingSettings = {};
|
|
}
|
|
}
|
|
newDetailSettings = JSON.stringify({
|
|
...existingSettings,
|
|
hierarchy_role: 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.column_name;
|
|
// 참조 테이블의 컬럼 정보 로드
|
|
loadReferenceTableColumns(value);
|
|
}
|
|
} else if (settingType === "entity_reference_column") {
|
|
// 🎯 Entity 참조 컬럼 변경 (조인할 컬럼)
|
|
referenceColumn = value;
|
|
const tableOption = referenceTableOptions.find((option) => option.value === col.reference_table);
|
|
newDetailSettings = tableOption ? `참조테이블: ${tableOption.label}` : "";
|
|
} else if (settingType === "entity_display_column") {
|
|
// 🎯 Entity 표시 컬럼 변경
|
|
displayColumn = value;
|
|
const tableOption = referenceTableOptions.find((option) => option.value === col.reference_table);
|
|
newDetailSettings = tableOption ? `참조테이블: ${tableOption.label} (${value})` : "";
|
|
}
|
|
|
|
return {
|
|
...col,
|
|
detail_settings: newDetailSettings,
|
|
code_info: codeInfo,
|
|
code_value: codeValue,
|
|
reference_table: referenceTable,
|
|
reference_column: referenceColumn,
|
|
display_column: displayColumn,
|
|
hierarchy_role: hierarchyRole,
|
|
};
|
|
}
|
|
return col;
|
|
}),
|
|
);
|
|
},
|
|
[commonCodeOptions, referenceTableOptions, loadReferenceTableColumns],
|
|
);
|
|
|
|
// 라벨 변경 핸들러 추가
|
|
const handleLabelChange = useCallback((columnName: string, newLabel: string) => {
|
|
setColumns((prev) =>
|
|
prev.map((col) => {
|
|
if (col.column_name === columnName) {
|
|
return {
|
|
...col,
|
|
display_name: 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.detail_settings || "";
|
|
|
|
if (column.input_type === "entity" && column.reference_table) {
|
|
// 기존 detailSettings를 파싱하거나 새로 생성
|
|
let existingSettings: Record<string, unknown> = {};
|
|
if (typeof column.detail_settings === "string" && column.detail_settings.trim().startsWith("{")) {
|
|
try {
|
|
existingSettings = JSON.parse(column.detail_settings);
|
|
} catch {
|
|
existingSettings = {};
|
|
}
|
|
}
|
|
|
|
// 엔티티 설정 추가
|
|
const entitySettings = {
|
|
...existingSettings,
|
|
entityTable: column.reference_table,
|
|
entityCodeColumn: column.reference_column || "id",
|
|
entityLabelColumn: column.display_column || "name",
|
|
placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요",
|
|
searchable: existingSettings.searchable ?? true,
|
|
};
|
|
|
|
finalDetailSettings = JSON.stringify(entitySettings);
|
|
}
|
|
|
|
// 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
|
|
if (column.input_type === "code" && column.hierarchy_role) {
|
|
let existingSettings: Record<string, unknown> = {};
|
|
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
|
try {
|
|
existingSettings = JSON.parse(finalDetailSettings);
|
|
} catch {
|
|
existingSettings = {};
|
|
}
|
|
}
|
|
|
|
const codeSettings = {
|
|
...existingSettings,
|
|
hierarchy_role: column.hierarchy_role,
|
|
};
|
|
|
|
finalDetailSettings = JSON.stringify(codeSettings);
|
|
}
|
|
|
|
const columnSetting = {
|
|
column_name: column.column_name,
|
|
column_label: column.display_name,
|
|
input_type: column.input_type || "text",
|
|
detail_settings: finalDetailSettings,
|
|
code_info: column.code_info || "",
|
|
code_value: column.code_value || "",
|
|
reference_table: column.reference_table || "",
|
|
reference_column: column.reference_column || "",
|
|
display_column: column.display_column || "",
|
|
category_ref: column.category_ref || null,
|
|
};
|
|
|
|
// console.log("저장할 컬럼 설정:", columnSetting);
|
|
|
|
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
|
columnSetting,
|
|
]);
|
|
|
|
if (response.data.success) {
|
|
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
|
if (column.input_type === "category" && !column.category_ref) {
|
|
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
|
try {
|
|
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
|
} catch (error) {
|
|
console.error("❌ 기존 매핑 삭제 실패:", error);
|
|
}
|
|
|
|
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
|
if (column.category_menus && column.category_menus.length > 0) {
|
|
|
|
// 직렬 await 대신 Promise.allSettled 로 병렬 호출 (메뉴가 많으면 직렬은 수십 초 멈춤)
|
|
const mappingResults = await Promise.allSettled(
|
|
column.category_menus.map((menuObjid) =>
|
|
createColumnMapping({
|
|
tableName: selectedTable,
|
|
logicalColumnName: column.column_name,
|
|
physicalColumnName: column.column_name,
|
|
menuObjid,
|
|
description: `${column.display_name} (메뉴별 카테고리)`,
|
|
}),
|
|
),
|
|
);
|
|
const successCount = mappingResults.filter(
|
|
(r) => r.status === "fulfilled" && r.value.success,
|
|
).length;
|
|
const failCount = mappingResults.length - successCount;
|
|
|
|
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.column_name === column.column_name ? column : col)));
|
|
|
|
// 저장 후 데이터 확인을 위해 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
|
await loadColumnTypes(selectedTable);
|
|
} else {
|
|
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
|
|
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// console.error("컬럼 설정 저장 실패:", error);
|
|
showErrorToast("컬럼 설정 저장에 실패했습니다", error, {
|
|
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
|
});
|
|
}
|
|
};
|
|
|
|
// 헤더 표시명/설명 인라인 저장 (PUT /label) — Google Docs 식 blur/Enter 커밋
|
|
const commitHeaderEdit = async () => {
|
|
if (!editingHeaderField || !selectedTable) {
|
|
setEditingHeaderField(null);
|
|
return;
|
|
}
|
|
const next = editingHeaderValue.trim();
|
|
const current = editingHeaderField === "label" ? tableLabel : tableDescription;
|
|
if (next === current) {
|
|
setEditingHeaderField(null);
|
|
return;
|
|
}
|
|
const newLabel = editingHeaderField === "label" ? next : tableLabel;
|
|
const newDescription = editingHeaderField === "description" ? next : tableDescription;
|
|
if (editingHeaderField === "label" && !newLabel) {
|
|
toast.error("표시명은 비울 수 없습니다.");
|
|
setEditingHeaderField(null);
|
|
return;
|
|
}
|
|
if (editingHeaderField === "label") setTableLabel(newLabel);
|
|
else setTableDescription(newDescription);
|
|
setEditingHeaderField(null);
|
|
try {
|
|
await apiClient.put(`/table-management/tables/${selectedTable}/label`, {
|
|
display_name: newLabel,
|
|
description: newDescription,
|
|
});
|
|
toast.success(editingHeaderField === "label" ? "표시명이 저장되었습니다." : "설명이 저장되었습니다.");
|
|
} catch (error: any) {
|
|
showErrorToast("저장에 실패했습니다", error, {
|
|
guidance: "잠시 후 다시 시도해 주세요.",
|
|
});
|
|
}
|
|
};
|
|
|
|
// 컬럼 설정만 일괄 저장 (헤더 라벨/설명은 inline 편집으로 즉시 저장됨)
|
|
const saveAllSettings = async () => {
|
|
if (!selectedTable) return;
|
|
if (isSaving) return; // 저장 중 중복 실행 방지
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
// 모든 컬럼 설정 저장
|
|
if (columns.length > 0) {
|
|
const columnSettings = columns.map((column) => {
|
|
// detailSettings 계산
|
|
let finalDetailSettings = column.detail_settings || "";
|
|
|
|
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
|
|
if (column.input_type === "entity" && column.reference_table) {
|
|
let existingSettings: Record<string, unknown> = {};
|
|
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
|
try {
|
|
existingSettings = JSON.parse(finalDetailSettings);
|
|
} catch {
|
|
existingSettings = {};
|
|
}
|
|
}
|
|
const entitySettings = {
|
|
...existingSettings,
|
|
entityTable: column.reference_table,
|
|
entityCodeColumn: column.reference_column || "id",
|
|
entityLabelColumn: column.display_column || "name",
|
|
};
|
|
finalDetailSettings = JSON.stringify(entitySettings);
|
|
}
|
|
|
|
// 🆕 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
|
|
if (column.input_type === "code" && column.hierarchy_role) {
|
|
let existingSettings: Record<string, unknown> = {};
|
|
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
|
try {
|
|
existingSettings = JSON.parse(finalDetailSettings);
|
|
} catch {
|
|
existingSettings = {};
|
|
}
|
|
}
|
|
const codeSettings = {
|
|
...existingSettings,
|
|
hierarchy_role: column.hierarchy_role,
|
|
};
|
|
finalDetailSettings = JSON.stringify(codeSettings);
|
|
}
|
|
|
|
return {
|
|
column_name: column.column_name,
|
|
column_label: column.display_name,
|
|
input_type: column.input_type || "text",
|
|
detail_settings: finalDetailSettings,
|
|
description: column.description || "",
|
|
code_info: column.code_info || "",
|
|
code_value: column.code_value || "",
|
|
reference_table: column.reference_table || "",
|
|
reference_column: column.reference_column || "",
|
|
display_column: column.display_column || "",
|
|
category_ref: column.category_ref || 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.input_type === "category" && !col.category_ref);
|
|
|
|
if (categoryColumns.length > 0) {
|
|
let totalSuccessCount = 0;
|
|
let totalFailCount = 0;
|
|
|
|
for (const column of categoryColumns) {
|
|
// 1. 먼저 기존 매핑 모두 삭제
|
|
try {
|
|
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
|
} catch (error) {
|
|
console.error("❌ 기존 매핑 삭제 실패:", error);
|
|
}
|
|
|
|
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) — 직렬 await 대신 Promise.allSettled 병렬 호출
|
|
if (column.category_menus && column.category_menus.length > 0) {
|
|
const mappingResults = await Promise.allSettled(
|
|
column.category_menus.map((menuObjid) =>
|
|
createColumnMapping({
|
|
tableName: selectedTable,
|
|
logicalColumnName: column.column_name,
|
|
physicalColumnName: column.column_name,
|
|
menuObjid,
|
|
description: `${column.display_name} (메뉴별 카테고리)`,
|
|
}),
|
|
),
|
|
);
|
|
const colSuccess = mappingResults.filter(
|
|
(r) => r.status === "fulfilled" && r.value.success,
|
|
).length;
|
|
totalSuccessCount += colSuccess;
|
|
totalFailCount += mappingResults.length - colSuccess;
|
|
}
|
|
}
|
|
|
|
if (totalSuccessCount > 0) {
|
|
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
|
} else if (totalFailCount > 0) {
|
|
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
|
|
} else {
|
|
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
|
|
}
|
|
} else {
|
|
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
|
|
}
|
|
|
|
// 저장 성공 후 원본 데이터 업데이트
|
|
setOriginalColumns([...columns]);
|
|
|
|
// 테이블 목록 새로고침 (라벨 변경 반영)
|
|
loadTables();
|
|
|
|
// 저장 후 데이터 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
|
await loadColumnTypes(selectedTable, 1, pageSize);
|
|
} 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.table_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
|
|
);
|
|
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
|
const q = searchTerm.trim().toLowerCase();
|
|
// 검색 매치 강도: 0=정확, 1=시작, 2=포함 — 낮을수록 위
|
|
const matchScore = (t: typeof tables[number]) => {
|
|
if (!q) return 0;
|
|
const tn = (t.table_name ?? "").toLowerCase();
|
|
const dn = (t.display_name ?? "").toLowerCase();
|
|
if (tn === q || dn === q) return 0;
|
|
if (tn.startsWith(q) || dn.startsWith(q)) return 1;
|
|
return 2;
|
|
};
|
|
return filtered.sort((a, b) => {
|
|
const sa = matchScore(a);
|
|
const sb = matchScore(b);
|
|
if (sa !== sb) return sa - sb;
|
|
const nameA = a.display_name || a.table_name;
|
|
const nameB = b.display_name || b.table_name;
|
|
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.table_name === selectedTable);
|
|
|
|
useEffect(() => {
|
|
loadTables();
|
|
loadCommonCodeCategories();
|
|
loadSecondLevelMenus();
|
|
}, []);
|
|
|
|
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
|
useEffect(() => {
|
|
if (columns.length > 0) {
|
|
const entityColumns = columns.filter(
|
|
(col) => col.input_type === "entity" && col.reference_table && col.reference_table !== "none",
|
|
);
|
|
|
|
entityColumns.forEach((col) => {
|
|
if (col.reference_table) {
|
|
// console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.column_name} -> ${col.reference_table}`);
|
|
loadReferenceTableColumns(col.reference_table);
|
|
}
|
|
});
|
|
}
|
|
}, [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);
|
|
}
|
|
// 이번 세션 동안 묻지 않기로 한 경우 즉시 적용
|
|
if (pkSkipConfirmSession) {
|
|
applyPkChange(newPkCols);
|
|
return;
|
|
}
|
|
// PK 변경은 확인 다이얼로그 표시
|
|
setPendingPkColumns(newPkCols);
|
|
setPkDialogOpen(true);
|
|
},
|
|
[constraints.primaryKey?.columns, pkSkipConfirmSession],
|
|
);
|
|
|
|
// PK 변경 실제 적용 (다이얼로그 거치지 않거나 거친 후 호출)
|
|
const applyPkChange = async (newPkCols: string[]) => {
|
|
if (!selectedTable) return;
|
|
try {
|
|
if (newPkCols.length === 0) {
|
|
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
|
|
return;
|
|
}
|
|
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
|
|
columns: newPkCols,
|
|
});
|
|
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: "컬럼 정보를 확인하고 다시 시도해 주세요.",
|
|
});
|
|
}
|
|
};
|
|
|
|
// PK 변경 확인 (다이얼로그에서 호출)
|
|
const handlePkConfirm = async () => {
|
|
setPkDialogOpen(false);
|
|
await applyPkChange(pendingPkColumns);
|
|
};
|
|
|
|
// 인덱스 토글 핸들러 (일반 인덱스만 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`, {
|
|
column_name: columnName,
|
|
index_type: 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.is_unique && 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.column_name === columnName
|
|
? { ...col, is_unique: 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.column_name === columnName
|
|
? { ...col, is_nullable: 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);
|
|
};
|
|
|
|
// 컬럼 삭제 (DBeaver 방식 — FK 참조 있으면 Postgres 가 거부)
|
|
const handleDeleteColumnClick = (columnName: string) => {
|
|
setColumnToDelete(columnName);
|
|
setDeleteColumnDialogOpen(true);
|
|
};
|
|
|
|
const handleDeleteColumn = async () => {
|
|
if (!selectedTable || !columnToDelete) return;
|
|
setIsDeletingColumn(true);
|
|
try {
|
|
const result = await ddlApi.dropColumn(selectedTable, columnToDelete);
|
|
if (result.success) {
|
|
toast.success(`컬럼 '${columnToDelete}'이 삭제되었습니다.`);
|
|
if (selectedColumn === columnToDelete) setSelectedColumn(null);
|
|
await loadColumnTypes(selectedTable);
|
|
} else {
|
|
showErrorToast("컬럼 삭제에 실패했습니다", result.message, {
|
|
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
showErrorToast("컬럼 삭제에 실패했습니다", error, {
|
|
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
|
|
});
|
|
} finally {
|
|
setIsDeletingColumn(false);
|
|
setDeleteColumnDialogOpen(false);
|
|
setColumnToDelete("");
|
|
}
|
|
};
|
|
|
|
// 테이블 삭제 실행
|
|
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.table_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(table.display_name && table.display_name.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
);
|
|
setSelectedTableIds(new Set(filteredTables.map((table) => table.table_name)));
|
|
} 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-full min-h-0 w-full 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>
|
|
|
|
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
|
|
<div className="relative flex flex-1 overflow-hidden">
|
|
{/* 좌측: 테이블 목록 (240px) */}
|
|
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
|
|
{/* 검색 */}
|
|
<div className="flex-shrink-0 p-3 pb-0">
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
|
<Input
|
|
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="bg-background h-[34px] pl-8 text-xs"
|
|
/>
|
|
</div>
|
|
{isSuperAdmin && (
|
|
<div className="mt-2 flex min-h-9 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.table_name))
|
|
}
|
|
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.table_name;
|
|
const prevTable = idx > 0 ? filteredTables[idx - 1] : null;
|
|
const isKo = /^[가-힣ㄱ-ㅎ]/.test(table.display_name || table.table_name);
|
|
const prevIsKo = prevTable ? /^[가-힣ㄱ-ㅎ]/.test(prevTable.display_name || prevTable.table_name) : null;
|
|
const showDivider = idx === 0 || (prevIsKo !== null && isKo !== prevIsKo);
|
|
|
|
return (
|
|
<div key={table.table_name}>
|
|
{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-1.5 transition-colors",
|
|
isActive
|
|
? "bg-accent text-foreground"
|
|
: "text-foreground/80 hover:bg-accent/50",
|
|
)}
|
|
onClick={() => handleTableSelect(table.table_name)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handleTableSelect(table.table_name);
|
|
}
|
|
}}
|
|
>
|
|
{isActive && (
|
|
<div className="bg-primary absolute top-1.5 bottom-1.5 left-0 w-[3px] rounded-r" />
|
|
)}
|
|
{isSuperAdmin && (
|
|
<Checkbox
|
|
checked={selectedTableIds.has(table.table_name)}
|
|
onCheckedChange={(checked) => handleTableCheck(table.table_name, checked as boolean)}
|
|
aria-label={`${table.display_name || table.table_name} 선택`}
|
|
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-[13px] leading-tight",
|
|
isActive ? "font-bold" : "font-medium",
|
|
)}>
|
|
{table.display_name || table.table_name}
|
|
</span>
|
|
</div>
|
|
<div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
|
|
{table.table_name}
|
|
</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.column_count}
|
|
</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>
|
|
) : (
|
|
<>
|
|
{/* 중앙 헤더: inline click-to-edit (Google Docs / Notion 패턴) */}
|
|
<div className="bg-card flex flex-shrink-0 items-start gap-3 border-b px-5 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
{/* 표시명 (display_name) — 클릭하면 그 자리에서 편집 */}
|
|
{editingHeaderField === "label" ? (
|
|
<Input
|
|
autoFocus
|
|
value={editingHeaderValue}
|
|
onChange={(e) => setEditingHeaderValue(e.target.value)}
|
|
onBlur={commitHeaderEdit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
e.currentTarget.blur();
|
|
} else if (e.key === "Escape") {
|
|
setEditingHeaderField(null);
|
|
}
|
|
}}
|
|
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
|
|
/>
|
|
) : (
|
|
<div className="group flex items-center gap-1.5">
|
|
<span className="text-[15px] font-bold tracking-tight">
|
|
{tableLabel || (
|
|
<span className="text-muted-foreground/60">{selectedTable}</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingHeaderValue(tableLabel);
|
|
setEditingHeaderField("label");
|
|
}}
|
|
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
|
title="표시명 편집"
|
|
aria-label="표시명 편집"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* table_name (코드, 편집 불가) */}
|
|
<div className="-mx-2 px-2 text-muted-foreground font-mono text-[11px] tracking-tight">
|
|
{selectedTable}
|
|
</div>
|
|
{/* 설명 (description) — 클릭하면 그 자리에서 편집 */}
|
|
{editingHeaderField === "description" ? (
|
|
<Input
|
|
autoFocus
|
|
value={editingHeaderValue}
|
|
onChange={(e) => setEditingHeaderValue(e.target.value)}
|
|
onBlur={commitHeaderEdit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
e.currentTarget.blur();
|
|
} else if (e.key === "Escape") {
|
|
setEditingHeaderField(null);
|
|
}
|
|
}}
|
|
placeholder="이 테이블에 대한 짧은 설명"
|
|
className="mt-1 h-7 -mx-2 px-2 text-xs"
|
|
/>
|
|
) : (
|
|
<div className="group mt-0.5 flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">
|
|
{tableDescription || (
|
|
<span className="text-muted-foreground/50">+ 설명 추가</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingHeaderValue(tableDescription);
|
|
setEditingHeaderField("description");
|
|
}}
|
|
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
|
title="설명 편집"
|
|
aria-label="설명 편집"
|
|
>
|
|
<Pencil className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
onClick={saveAllSettings}
|
|
disabled={!selectedTable || columns.length === 0 || isSaving}
|
|
size="sm"
|
|
className="h-8 flex-shrink-0 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>
|
|
) : (
|
|
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
|
|
<TabsList className="h-auto w-full shrink-0 justify-start gap-1 rounded-none border-b bg-transparent p-0">
|
|
<TabsTrigger
|
|
value="columns"
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-none border-b-2 border-transparent bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
|
|
"hover:text-foreground",
|
|
"data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none",
|
|
)}
|
|
>
|
|
<Columns3 className="h-4 w-4" />
|
|
컬럼
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="references"
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-none border-b-2 border-transparent bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
|
|
"hover:text-foreground",
|
|
"data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none",
|
|
)}
|
|
>
|
|
<Link2 className="h-4 w-4" />
|
|
참조
|
|
{(() => {
|
|
const refCount = columns.filter((c) =>
|
|
["entity", "code", "category", "numbering"].includes(c.input_type),
|
|
).length;
|
|
return refCount > 0 ? (
|
|
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[11px]">
|
|
{refCount}
|
|
</Badge>
|
|
) : null;
|
|
})()}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
|
|
<TypeOverviewStrip
|
|
columns={columns}
|
|
activeFilter={typeFilter}
|
|
onFilterChange={setTypeFilter}
|
|
/>
|
|
<ColumnGrid
|
|
columns={columns}
|
|
selectedColumn={selectedColumn}
|
|
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
|
onColumnChange={(columnName, field, value) => {
|
|
if (field === "is_unique") {
|
|
const currentColumn = columns.find((c) => c.column_name === columnName);
|
|
if (currentColumn) {
|
|
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
|
}
|
|
return;
|
|
}
|
|
if (field === "is_nullable") {
|
|
const currentColumn = columns.find((c) => c.column_name === columnName);
|
|
if (currentColumn) {
|
|
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
|
}
|
|
return;
|
|
}
|
|
const idx = columns.findIndex((c) => c.column_name === columnName);
|
|
if (idx >= 0) handleColumnChange(idx, field, value);
|
|
}}
|
|
constraints={constraints}
|
|
typeFilter={typeFilter}
|
|
getColumnIndexState={getColumnIndexState}
|
|
onPkToggle={handlePkToggle}
|
|
onIndexToggle={(columnName, checked) =>
|
|
handleIndexToggle(columnName, "index", checked)
|
|
}
|
|
onDeleteColumn={handleDeleteColumnClick}
|
|
tables={tables}
|
|
referenceTableColumns={referenceTableColumns}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
|
|
<ReferenceListView
|
|
columns={columns}
|
|
tables={tables}
|
|
referenceTableColumns={referenceTableColumns}
|
|
selectedColumn={selectedColumn}
|
|
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측: 상세 패널
|
|
- 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane
|
|
- 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
|
|
<div
|
|
className={cn(
|
|
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
|
|
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
|
|
"xl:relative xl:z-0 xl:flex-shrink-0 xl:translate-x-0 xl:pointer-events-auto xl:shadow-none xl:transition-none",
|
|
)}
|
|
>
|
|
<ColumnDetailPanel
|
|
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
|
|
tables={tables}
|
|
referenceTableColumns={referenceTableColumns}
|
|
secondLevelMenus={secondLevelMenus}
|
|
numberingRules={[]}
|
|
onColumnChange={(field, value) => {
|
|
if (!selectedColumn) return;
|
|
if (field === "input_type") {
|
|
handleInputTypeChange(selectedColumn, value as string);
|
|
return;
|
|
}
|
|
// 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장
|
|
if (field === "is_nullable") {
|
|
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
|
|
if (currentColumn) {
|
|
handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES");
|
|
}
|
|
return;
|
|
}
|
|
if (field === "is_unique") {
|
|
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
|
|
if (currentColumn) {
|
|
handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO");
|
|
}
|
|
return;
|
|
}
|
|
if (field === "reference_table" && value) {
|
|
loadReferenceTableColumns(value as string);
|
|
}
|
|
setColumns((prev) =>
|
|
prev.map((c) =>
|
|
c.column_name === selectedColumn ? { ...c, [field]: value } : c,
|
|
),
|
|
);
|
|
}}
|
|
onClose={() => setSelectedColumn(null)}
|
|
onLoadReferenceColumns={loadReferenceTableColumns}
|
|
codeInfoOptions={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?.table_name) {
|
|
setSelectedTable(result.data.table_name);
|
|
setCurrentPage(1);
|
|
setColumns([]);
|
|
await loadColumnTypes(result.data.table_name, 1, pageSize);
|
|
}
|
|
// 선택 초기화
|
|
setSelectedTableIds(new Set());
|
|
// 상태 초기화
|
|
setDuplicateModalMode("create");
|
|
setDuplicateSourceTable(null);
|
|
}}
|
|
mode={duplicateModalMode}
|
|
source_table_name={duplicateSourceTable || undefined}
|
|
/>
|
|
|
|
<AddColumnModal
|
|
isOpen={addColumnModalOpen}
|
|
onClose={() => setAddColumnModalOpen(false)}
|
|
table_name={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>
|
|
|
|
{/* 컬럼 삭제 확인 다이얼로그 */}
|
|
<Dialog open={deleteColumnDialogOpen} onOpenChange={setDeleteColumnDialogOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">컬럼 삭제 확인</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
정말 삭제할까요? 이 작업은 되돌릴 수 없습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{columnToDelete && (
|
|
<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">{selectedTable}.{columnToDelete}</span> 컬럼과 해당 컬럼의
|
|
모든 데이터가 영구적으로 삭제됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setDeleteColumnDialogOpen(false);
|
|
setColumnToDelete("");
|
|
}}
|
|
disabled={isDeletingColumn}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDeleteColumn}
|
|
disabled={isDeletingColumn}
|
|
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isDeletingColumn ? (
|
|
<>
|
|
<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) => {
|
|
const colInfo = columns.find((c) => c.column_name === col);
|
|
return (
|
|
<Badge key={col} variant="secondary" className="text-xs">
|
|
{colInfo?.display_name && colInfo.display_name !== col
|
|
? `${colInfo.display_name} (${col})`
|
|
: col}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
|
)}
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
|
<Checkbox
|
|
checked={pkSkipConfirmSession}
|
|
onCheckedChange={(v) => setPkSkipConfirmSession(v === true)}
|
|
/>
|
|
이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 편함)
|
|
</label>
|
|
</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>
|
|
);
|
|
}
|