diff --git a/backend-spring/src/main/java/com/erp/controller/DdlController.java b/backend-spring/src/main/java/com/erp/controller/DdlController.java index 205f2969..3ad8a194 100644 --- a/backend-spring/src/main/java/com/erp/controller/DdlController.java +++ b/backend-spring/src/main/java/com/erp/controller/DdlController.java @@ -91,6 +91,32 @@ public class DdlController { return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message"))); } + /** + * DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제 + */ + @DeleteMapping("/tables/{tableName}/columns/{columnName}") + public ResponseEntity> dropColumn( + @PathVariable String tableName, + @PathVariable String columnName, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("user_id") String userId) { + + if (!isSuperAdmin(companyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); + } + + Map result = ddlService.dropColumn(tableName, columnName, companyCode, userId); + + if (Boolean.TRUE.equals(result.get("success"))) { + return ResponseEntity.ok(ApiResponse.success(Map.of( + "table_name", result.get("table_name"), + "column_name", result.get("column_name"), + "executed_query", result.get("executed_query") + ), (String) result.get("message"))); + } + return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message"))); + } + /** * DELETE /api/ddl/tables/{tableName} - 테이블 삭제 */ diff --git a/backend-spring/src/main/java/com/erp/service/DdlService.java b/backend-spring/src/main/java/com/erp/service/DdlService.java index 745b810e..0da2f6c6 100644 --- a/backend-spring/src/main/java/com/erp/service/DdlService.java +++ b/backend-spring/src/main/java/com/erp/service/DdlService.java @@ -226,6 +226,79 @@ public class DdlService extends BaseService { } } + // ───────────────────────────────────────────────────────────────────────── + // DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출) + // ───────────────────────────────────────────────────────────────────────── + + public Map dropColumn(String tableName, String columnName, + String companyCode, String userId) { + // 1. 시스템 테이블 보호 + if (SYSTEM_TABLES.contains(tableName.toLowerCase())) { + String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, + "SYSTEM_TABLE_PROTECTED", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED"); + } + + // 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer) + if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) { + String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, + "RESERVED_COLUMN_PROTECTED", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED"); + } + + // 3. 테이블/컬럼 존재 여부 + if (!tableExists(tableName)) { + String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND"); + } + if (!columnExists(tableName, columnName)) { + String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND"); + } + + // 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일) + String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\""; + + try { + transactionTemplate.execute(status -> { + jdbcTemplate.execute(ddlQuery); + // 컬럼 메타 청소 + jdbcTemplate.update( + "DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?", + tableName, columnName); + jdbcTemplate.update( + "DELETE FROM column_labels WHERE table_name = ? AND column_name = ?", + tableName, columnName); + return null; + }); + + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null); + log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId); + + return Map.of( + "success", true, + "message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.", + "table_name", tableName, + "column_name", columnName, + "executed_query", ddlQuery + ); + } catch (Exception e) { + String rawMsg = e.getMessage() != null ? e.getMessage() : ""; + String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key") + ? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)" + : ""; + String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, + "FAILED: " + rawMsg, false, errorMsg); + log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e); + return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED"); + } + } + // ───────────────────────────────────────────────────────────────────────── // VALIDATE // ───────────────────────────────────────────────────────────────────────── diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 1054a84f..ec166cbc 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -120,6 +120,9 @@ export default function TableManagementPage() { // 테이블 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); + const [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false); + const [columnToDelete, setColumnToDelete] = useState(""); + const [isDeletingColumn, setIsDeletingColumn] = useState(false); const [isDeleting, setIsDeleting] = useState(false); // PK/인덱스 관리 상태 @@ -984,7 +987,20 @@ export default function TableManagementPage() { (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); @@ -1188,6 +1204,37 @@ export default function TableManagementPage() { 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; @@ -1678,6 +1725,7 @@ export default function TableManagementPage() { onIndexToggle={(columnName, checked) => handleIndexToggle(columnName, "index", checked) } + onDeleteColumn={handleDeleteColumnClick} tables={tables} referenceTableColumns={referenceTableColumns} /> @@ -1863,6 +1911,62 @@ export default function TableManagementPage() { + + {/* 컬럼 삭제 확인 다이얼로그 */} + + + + 컬럼 삭제 확인 + + 정말 삭제할까요? 이 작업은 되돌릴 수 없습니다. + + + + {columnToDelete && ( +
+
+

경고

+

+ {selectedTable}.{columnToDelete} 컬럼과 해당 컬럼의 + 모든 데이터가 영구적으로 삭제됩니다. +

+
+
+ )} + + + + + +
+
)} diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index c50c8e0a..ec591e91 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -322,7 +322,7 @@ export function CreateTableModal({ return ( - + @@ -336,7 +336,7 @@ export function CreateTableModal({ -
+
{/* 테이블 기본 정보 */}
diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index 2a17b716..74191797 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -1,9 +1,15 @@ "use client"; import React, { useMemo } from "react"; -import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; +import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { ColumnTypeInfo, TableInfo } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; @@ -24,6 +30,7 @@ export interface ColumnGridProps { getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; onPkToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void; + onDeleteColumn?: (columnName: string) => void; /** 호버 시 한글 라벨 표시용 (Badge title) */ tables?: TableInfo[]; referenceTableColumns?: Record; @@ -57,6 +64,7 @@ export function ColumnGrid({ getColumnIndexState: externalGetIndexState, onPkToggle, onIndexToggle, + onDeleteColumn, tables, referenceTableColumns, }: ColumnGridProps) { @@ -286,19 +294,32 @@ export function ColumnGrid({
- + + + + + e.stopPropagation()}> + { + e.preventDefault(); + onDeleteColumn?.(column.column_name); + }} + > + + 컬럼 삭제 + + +
); diff --git a/frontend/lib/api/ddl.ts b/frontend/lib/api/ddl.ts index 0c372b64..471306f2 100644 --- a/frontend/lib/api/ddl.ts +++ b/frontend/lib/api/ddl.ts @@ -37,6 +37,14 @@ export const ddlApi = { return response.data; }, + /** + * 컬럼 삭제 (ALTER TABLE ... DROP COLUMN) + */ + dropColumn: async (tableName: string, columnName: string): Promise => { + const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`); + return response.data; + }, + /** * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) */