diff --git a/backend-spring/build.gradle b/backend-spring/build.gradle index 457ea85b..65b211d0 100644 --- a/backend-spring/build.gradle +++ b/backend-spring/build.gradle @@ -33,6 +33,11 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' implementation 'org.postgresql:postgresql' + // 외부 커넥션 테스트용 JDBC 드라이버 (runtimeOnly — 내부 비즈니스 DB 는 PostgreSQL 만 사용) + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1' + runtimeOnly 'com.mysql:mysql-connector-j:8.4.0' + runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11' + runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' implementation 'org.flywaydb:flyway-core' 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/provisioning/DataCopier.java b/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java index 165b56be..1d772e20 100644 --- a/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java +++ b/backend-spring/src/main/java/com/erp/provisioning/DataCopier.java @@ -100,13 +100,22 @@ public class DataCopier { try (Statement us = dst.createStatement()) { for (String[] r : rows) { String seq = r[0], tbl = r[1], col = r[2], coltype = r[3]; - if (!isIntegerLike(coltype)) { + String sql; + if (isIntegerLike(coltype)) { + sql = String.format( + "SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))", + seq.replace("'", "''"), col, tbl); + } else if (isVarcharLike(coltype)) { + // V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고, + // INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가 + // 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산. + sql = String.format( + "SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))", + seq.replace("'", "''"), col, tbl, col); + } else { skippedType++; continue; } - String sql = String.format( - "SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))", - seq.replace("'", "''"), col, tbl); try { us.execute(sql); updated++; @@ -117,14 +126,8 @@ public class DataCopier { } } } - // invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때 - // nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상. - if (updated == 0 && skippedErr == 0) { - log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size()); - } else { - log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}", - updated, skippedType, skippedErr, rows.size()); - } + log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}", + updated, skippedType, skippedErr, rows.size()); return updated; } @@ -135,6 +138,12 @@ public class DataCopier { || t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2"); } + private static boolean isVarcharLike(String coltype) { + if (coltype == null) return false; + String t = coltype.toLowerCase(); + return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text"); + } + private List listColumns(Connection conn, String table) throws SQLException { List cols = new ArrayList<>(); try (PreparedStatement ps = conn.prepareStatement( 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/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java b/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java index ef319b94..f81fe993 100644 --- a/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java +++ b/backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java @@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService { private Map executeConnectionTest( String dbType, Map conn, String password) { + String type = dbType == null ? "" : dbType.toLowerCase(); String host = str(conn, "host"); - int port = toInt(conn, "port", 5432); + int port = toInt(conn, "port", defaultPort(type)); String database = str(conn, "database_name"); String username = str(conn, "username"); String sslEnabled = str(conn, "ssl_enabled"); int connTimeout = toInt(conn, "connection_timeout", 30); + boolean ssl = "Y".equalsIgnoreCase(sslEnabled); - if (!"postgresql".equalsIgnoreCase(dbType)) { - Map result = new LinkedHashMap<>(); - result.put("success", false); - result.put("message", "이 버전에서는 PostgreSQL 연결만 테스트가 지원됩니다."); - return result; - } - - String url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database); + String url; Properties props = new Properties(); - props.setProperty("user", username); - props.setProperty("password", password); - props.setProperty("connect_timeout", String.valueOf(connTimeout)); - props.setProperty("socket_timeout", "30"); - if ("Y".equalsIgnoreCase(sslEnabled)) { - props.setProperty("ssl", "true"); - props.setProperty("sslmode", "require"); + if (username != null) props.setProperty("user", username); + if (password != null) props.setProperty("password", password); + + switch (type) { + case "postgresql" -> { + url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database); + props.setProperty("connect_timeout", String.valueOf(connTimeout)); + props.setProperty("socket_timeout", "30"); + if (ssl) { + props.setProperty("ssl", "true"); + props.setProperty("sslmode", "require"); + } + } + case "mysql" -> { + url = String.format("jdbc:mysql://%s:%d/%s", host, port, database); + props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000)); + props.setProperty("socketTimeout", "30000"); + props.setProperty("useSSL", String.valueOf(ssl)); + props.setProperty("allowPublicKeyRetrieval", "true"); + } + case "mariadb" -> { + url = String.format("jdbc:mariadb://%s:%d/%s", host, port, database); + props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000)); + props.setProperty("socketTimeout", "30000"); + if (ssl) props.setProperty("useSsl", "true"); + } + case "mssql", "sqlserver" -> { + StringBuilder sb = new StringBuilder() + .append("jdbc:sqlserver://").append(host).append(':').append(port) + .append(";databaseName=").append(database) + .append(";loginTimeout=").append(connTimeout) + .append(";encrypt=").append(ssl ? "true;trustServerCertificate=true" : "false"); + url = sb.toString(); + } + case "sqlite" -> { + // SQLite: host/port 무의미. database_name 을 파일 경로로 사용 (비면 in-memory) + url = "jdbc:sqlite:" + (database != null && !database.isBlank() ? database : ":memory:"); + } + default -> { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("message", "지원하지 않는 DB 타입입니다: " + dbType); + return result; + } } try (Connection c = DriverManager.getConnection(url, props); @@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService { Map result = new LinkedHashMap<>(); result.put("success", true); result.put("message", "연결 성공"); - result.put("details", Map.of("host", host, "database", database, "port", port)); + Map details = new LinkedHashMap<>(); + details.put("host", host == null ? "" : host); + details.put("database", database == null ? "" : database); + details.put("port", port); + result.put("details", details); return result; } catch (SQLException e) { log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage()); @@ -342,6 +378,16 @@ public class ExternalDbConnectionService extends BaseService { } } + private int defaultPort(String dbType) { + if (dbType == null) return 5432; + return switch (dbType.toLowerCase()) { + case "mysql", "mariadb" -> 3306; + case "mssql", "sqlserver" -> 1433; + case "sqlite" -> 0; + default -> 5432; + }; + } + // ── SQL 쿼리 실행 (SELECT only) ──────────────────────────────────────────── public Map executeQuery(long id, String sql) { diff --git a/backend-spring/src/main/resources/mapper/externalDbConnection.xml b/backend-spring/src/main/resources/mapper/externalDbConnection.xml index 4dbac711..ad9fda03 100644 --- a/backend-spring/src/main/resources/mapper/externalDbConnection.xml +++ b/backend-spring/src/main/resources/mapper/externalDbConnection.xml @@ -81,7 +81,7 @@ , E.CREATED_DATE , E.UPDATED_DATE FROM EXTERNAL_DB_CONNECTIONS E - WHERE E.ID = #{id} + WHERE E.ID = #{id}::varchar @@ -109,14 +109,14 @@ , CREATED_DATE , UPDATED_DATE FROM EXTERNAL_DB_CONNECTIONS - WHERE ID = #{id} + WHERE ID = #{id}::varchar @@ -134,7 +134,7 @@ FROM EXTERNAL_DB_CONNECTIONS WHERE CONNECTION_NAME = #{connection_name} AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') - AND ID != #{exclude_id} + AND ID != #{exclude_id}::varchar LIMIT 1 @@ -166,13 +166,13 @@ , #{description} , #{db_type} , #{host} - , #{port} + , #{port}::varchar , #{database_name} , #{username} , #{password} - , #{connection_timeout} - , #{query_timeout} - , #{max_connections} + , #{connection_timeout}::varchar + , #{query_timeout}::varchar + , #{max_connections}::varchar , #{ssl_enabled} , #{ssl_cert_path} , #{connection_options}::JSONB @@ -193,13 +193,13 @@ DESCRIPTION = #{description}, DB_TYPE = #{db_type}, HOST = #{host}, - PORT = #{port}, + PORT = #{port}::varchar, DATABASE_NAME = #{database_name}, USERNAME = #{username}, PASSWORD = #{password}, - CONNECTION_TIMEOUT = #{connection_timeout}, - QUERY_TIMEOUT = #{query_timeout}, - MAX_CONNECTIONS = #{max_connections}, + CONNECTION_TIMEOUT = #{connection_timeout}::varchar, + QUERY_TIMEOUT = #{query_timeout}::varchar, + MAX_CONNECTIONS = #{max_connections}::varchar, SSL_ENABLED = #{ssl_enabled}, SSL_CERT_PATH = #{ssl_cert_path}, CONNECTION_OPTIONS = #{connection_options}::JSONB, @@ -208,13 +208,13 @@ UPDATED_BY = #{updated_by}, UPDATED_DATE = NOW() - WHERE ID = #{id} + WHERE ID = #{id}::varchar DELETE FROM EXTERNAL_DB_CONNECTIONS - WHERE ID = #{id} + WHERE ID = #{id}::varchar AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index d73484b7..87b444a5 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -35,6 +35,7 @@ import { } from "@/lib/api/batch"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { CrossTenantBanner } from "@/components/common/CrossTenantBanner"; +import { Pagination } from "@/components/common/Pagination"; import { useTabStore } from "@/stores/tabStore"; function cronToKorean(cron: string): string { @@ -331,6 +332,10 @@ export default function BatchManagementPage() { const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [togglingBatch, setTogglingBatch] = useState(null); + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + const loadBatchConfigs = useCallback(async () => { setLoading(true); try { @@ -364,6 +369,9 @@ export default function BatchManagementPage() { useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]); + // 검색/필터 변경 시 1페이지로 리셋 + useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]); + const handleRowClick = async (batchId: number) => { if (expandedBatch === batchId) { setExpandedBatch(null); return; } setExpandedBatch(batchId); @@ -443,14 +451,22 @@ export default function BatchManagementPage() { return true; }); + // 페이지네이션 계산 + const totalItems = filteredBatches.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + const safePage = Math.min(currentPage, totalPages); + const startIdx = (safePage - 1) * itemsPerPage; + const endIdx = Math.min(startIdx + itemsPerPage, totalItems); + const pagedBatches = filteredBatches.slice(startIdx, endIdx); + const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length; const inactiveBatches = batchConfigs.length - activeBatches; const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0; const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0; return ( -
-
+
+
{/* 헤더 */}
@@ -534,8 +550,8 @@ export default function BatchManagementPage() {
- {/* 배치 리스트 */} -
+ {/* 배치 리스트 - 자체 스크롤 */} +
{loading && batchConfigs.length === 0 && (
@@ -549,7 +565,7 @@ export default function BatchManagementPage() {
)} - {filteredBatches.map((batch) => { + {pagedBatches.map((batch) => { const batchId = batch.id!; const isExpanded = expandedBatch === batchId; const isExecuting = executingBatch === batchId; @@ -674,6 +690,29 @@ export default function BatchManagementPage() { })}
+ {/* 페이지네이션 — 리스트 영역 아래 고정 */} + {!loading && ( +
+ { + setItemsPerPage(size); + setCurrentPage(1); + }} + showPageSizeSelector + pageSizeOptions={[10, 20, 50, 100]} + /> +
+ )} + {/* 배치 타입 선택 모달 */} {isBatchTypeModalOpen && (
setIsBatchTypeModalOpen(false)}> diff --git a/frontend/app/(main)/admin/automaticMng/exconList/page.tsx b/frontend/app/(main)/admin/automaticMng/exconList/page.tsx index 89ecba46..15c2dbda 100644 --- a/frontend/app/(main)/admin/automaticMng/exconList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/exconList/page.tsx @@ -231,15 +231,15 @@ export default function ExternalConnectionsPage() { ) }, { key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true, render: (_v, row) => ( -
+
{testResults.has(row.id!) && ( - + {testResults.get(row.id!) ? "성공" : "실패"} )} @@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() { ]; return ( -
-
+
+
{/* 페이지 헤더 */} -
-

외부 커넥션 관리

-

외부 데이터베이스 및 REST API 연결 정보를 관리합니다

+
+

외부 커넥션 관리

+

외부 데이터베이스 및 REST API 연결 정보를 관리합니다

{/* 탭 */} - setActiveTab(value as ConnectionTabType)}> - - - + setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3"> + + + 데이터베이스 연결 - - + + REST API 연결 {/* 데이터베이스 연결 탭 */} - + {/* 검색 및 필터 */} -
-
-
- +
+
+
+ setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" + className="h-8 pl-9 text-xs" />
-
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() { isLoading={loading} emptyMessage="등록된 연결이 없습니다" skeletonCount={5} + compact + scrollContainer cardTitle={(c) => c.connection_name} cardSubtitle={(c) => {c.host}:{c.port}/{c.database_name}} cardHeaderRight={(c) => ( - + {c.is_active === "Y" ? "활성" : "비활성"} )} @@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() { @@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() { {/* REST API 연결 탭 */} - + 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/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index 28b2ad46..dbe2f146 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC return ( - - + + {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} -
+
{/* 기본 정보 */}

기본 정보

@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC
- +
{/* 연결 목록 */} {loading ? ( -
-
로딩 중...
+
+
로딩 중...
) : connections.length === 0 ? ( -
+
-

등록된 REST API 연결이 없습니다

+

등록된 REST API 연결이 없습니다

) : ( -
- - - - 연결명 - 회사 - 기본 URL - 인증 타입 - 헤더 수 - 상태 - 마지막 테스트 - 연결 테스트 - 작업 +
+
+ + + 연결명 + 회사 + 기본 URL + 인증 타입 + 헤더 수 + 상태 + 마지막 테스트 + 연결 테스트 + 작업 {connections.map((connection) => ( - - + +
{connection.connection_name}
{connection.description && ( -
+
{connection.description}
)}
- + {(connection as any).company_name || connection.company_code} - +
{connection.base_url}
- - {AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type} + + {AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type} - + {Object.keys(connection.default_headers || {}).length} - - + + {connection.is_active === "Y" ? "활성" : "비활성"} - + {connection.last_test_date ? ( -
-
{new Date(connection.last_test_date).toLocaleDateString()}
+
+ {new Date(connection.last_test_date).toLocaleDateString()} {connection.last_test_result === "Y" ? "성공" : "실패"} @@ -343,41 +343,41 @@ export function RestApiConnectionList() { - )} - -
+ +
{testResults.has(connection.id!) && ( - + {testResults.get(connection.id!) ? "성공" : "실패"} )}
- -
+ +
diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index 2a17b716..e2f5610e 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) { @@ -285,20 +293,37 @@ export function ColumnGrid({
-
- +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + + + + { + e.preventDefault(); + onDeleteColumn?.(column.column_name); + }} + > + + 컬럼 삭제 + + +
); diff --git a/frontend/components/common/ResponsiveDataView.tsx b/frontend/components/common/ResponsiveDataView.tsx index 3cd8f723..4f5790d7 100644 --- a/frontend/components/common/ResponsiveDataView.tsx +++ b/frontend/components/common/ResponsiveDataView.tsx @@ -92,6 +92,11 @@ export function ResponsiveDataView({ }: ResponsiveDataViewProps) { const rowHeight = compact ? "h-10" : "h-16"; const headHeight = compact ? "h-9" : "h-12"; + const bodyText = compact ? "text-xs" : "text-sm"; + const headText = compact ? "text-xs" : "text-sm"; + const cellPad = compact ? "px-3" : ""; + const cardTitleClass = compact ? "text-sm" : "text-base"; + const cardSubText = compact ? "text-xs" : "text-sm"; // cardFields 미지정 시 columns에서 자동 생성 function resolveCardFields(item: T): RDVCardField[] { if (typeof cardFields === "function") return cardFields(item); @@ -233,16 +238,20 @@ export function ResponsiveDataView({ {/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
-
+
@@ -250,7 +259,7 @@ export function ResponsiveDataView({ {col.label} @@ -258,7 +267,7 @@ export function ResponsiveDataView({ {renderActions && ( {actionsLabel || "작업"} @@ -278,7 +287,7 @@ export function ResponsiveDataView({ {columns.map((col) => ( {col.render ? col.render(getNestedValue(item, col.key), item, index) @@ -286,7 +295,7 @@ export function ResponsiveDataView({ ))} {renderActions && ( - +
{renderActions(item)}
)} @@ -319,11 +328,11 @@ export function ResponsiveDataView({ {/* 카드 헤더 */}
-

+

{cardTitle(item)}

{cardSubtitle && ( -

+

{cardSubtitle(item)}

)} @@ -337,7 +346,7 @@ export function ResponsiveDataView({ {fields.length > 0 && (
{fields.map((field, i) => ( -
+
{field.label} 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; + }, + /** * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) */