Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m44s
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m44s
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<ApiResponse<?>> 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<String, Object> 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} - 테이블 삭제
|
||||
*/
|
||||
|
||||
@@ -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<String> listColumns(Connection conn, String table) throws SQLException {
|
||||
List<String> cols = new ArrayList<>();
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
|
||||
@@ -226,6 +226,79 @@ public class DdlService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> 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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
private Map<String, Object> executeConnectionTest(
|
||||
String dbType, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "연결 성공");
|
||||
result.put("details", Map.of("host", host, "database", database, "port", port));
|
||||
Map<String, Object> 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<String, Object> executeQuery(long id, String sql) {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
, E.CREATED_DATE
|
||||
, E.UPDATED_DATE
|
||||
FROM EXTERNAL_DB_CONNECTIONS E
|
||||
WHERE E.ID = #{id}
|
||||
WHERE E.ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
|
||||
@@ -109,14 +109,14 @@
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 비밀번호만 조회 -->
|
||||
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
|
||||
SELECT PASSWORD
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 이름+회사 중복 확인 -->
|
||||
@@ -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
|
||||
</select>
|
||||
|
||||
@@ -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 @@
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="db_type != null">DB_TYPE = #{db_type},</if>
|
||||
<if test="host != null">HOST = #{host},</if>
|
||||
<if test="port != null">PORT = #{port},</if>
|
||||
<if test="port != null">PORT = #{port}::varchar,</if>
|
||||
<if test="database_name != null">DATABASE_NAME = #{database_name},</if>
|
||||
<if test="username != null">USERNAME = #{username},</if>
|
||||
<if test="password != null">PASSWORD = #{password},</if>
|
||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if>
|
||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if>
|
||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if>
|
||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
|
||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
|
||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections}::varchar,</if>
|
||||
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
|
||||
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
|
||||
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
|
||||
@@ -208,13 +208,13 @@
|
||||
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</update>
|
||||
|
||||
<!-- 삭제 -->
|
||||
<delete id="deleteExternalDbConnection" parameterType="map">
|
||||
DELETE FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
@@ -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<number | null>(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 (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="w-full space-y-4 px-4 py-6 sm:px-6">
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-6 sm:px-6">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -534,8 +550,8 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 리스트 */}
|
||||
<div className="space-y-1.5">
|
||||
{/* 배치 리스트 - 자체 스크롤 */}
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{loading && batchConfigs.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
@@ -549,7 +565,7 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 — 리스트 영역 아래 고정 */}
|
||||
{!loading && (
|
||||
<div className="shrink-0 rounded-lg border bg-card p-2 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
|
||||
@@ -231,15 +231,15 @@ export default function ExternalConnectionsPage() {
|
||||
) },
|
||||
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
|
||||
disabled={testingConnections.has(row.id!)}
|
||||
className="h-9 text-sm">
|
||||
className="h-7 px-2 text-xs">
|
||||
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(row.id!) && (
|
||||
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(row.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4 sm:px-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
<div className="shrink-0 space-y-0.5 border-b pb-3">
|
||||
<h1 className="text-lg font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
||||
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
데이터베이스 연결
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
REST API 연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 데이터베이스 연결 탭 */}
|
||||
<TabsContent value="database" className="space-y-6">
|
||||
<TabsContent value="database" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[260px]">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="연결명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
|
||||
<SelectValue placeholder="DB 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedDbTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
|
||||
isLoading={loading}
|
||||
emptyMessage="등록된 연결이 없습니다"
|
||||
skeletonCount={5}
|
||||
compact
|
||||
scrollContainer
|
||||
cardTitle={(c) => c.connection_name}
|
||||
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
|
||||
cardHeaderRight={(c) => (
|
||||
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
||||
<Badge variant={c.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{c.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
|
||||
disabled={testingConnections.has(c.id!)}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
|
||||
setSelectedConnection(c);
|
||||
setSqlModalOpen(true);
|
||||
}}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
<Terminal className="h-4 w-4" />
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
SQL
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
편집
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
|
||||
</TabsContent>
|
||||
|
||||
{/* REST API 연결 탭 */}
|
||||
<TabsContent value="rest-api" className="space-y-6">
|
||||
<TabsContent value="rest-api" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
|
||||
<RestApiConnectionList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -120,6 +120,9 @@ export default function TableManagementPage() {
|
||||
// 테이블 삭제 확인 다이얼로그 상태
|
||||
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/인덱스 관리 상태
|
||||
@@ -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() {
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ export function CreateTableModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
@@ -336,7 +336,7 @@ export function CreateTableModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-y-auto pr-1">
|
||||
{/* 테이블 기본 정보 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="-mr-1 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 sm:space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<h3 className="text-sm font-semibold sm:text-base">기본 정보</h3>
|
||||
@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="shrink-0 gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -219,27 +219,27 @@ export function RestApiConnectionList() {
|
||||
return (
|
||||
<>
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* 검색 */}
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<div className="relative w-full sm:w-[260px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="연결명 또는 URL로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입 필터 */}
|
||||
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
|
||||
<SelectValue placeholder="인증 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedAuthTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -248,12 +248,12 @@ export function RestApiConnectionList() {
|
||||
|
||||
{/* 활성 상태 필터 */}
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -262,79 +262,79 @@ export function RestApiConnectionList() {
|
||||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 연결 추가
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 연결 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center bg-card">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
<div className="flex h-40 shrink-0 items-center justify-center rounded-lg border bg-card">
|
||||
<div className="text-xs text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center bg-card">
|
||||
<div className="flex h-40 shrink-0 flex-col items-center justify-center rounded-lg border bg-card">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">등록된 REST API 연결이 없습니다</p>
|
||||
<p className="text-xs text-muted-foreground">등록된 REST API 연결이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">헤더 수</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<Table divClassName="flex-1 overflow-auto">
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||
<TableRow className="border-b bg-muted hover:bg-muted">
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">회사</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">헤더 수</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">상태</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-right text-xs font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connections.map((connection) => (
|
||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<div className="max-w-[200px]">
|
||||
<div className="truncate font-medium" title={connection.connection_name}>
|
||||
{connection.connection_name}
|
||||
</div>
|
||||
{connection.description && (
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}>
|
||||
<div className="text-muted-foreground mt-0.5 truncate text-[10px]" title={connection.description}>
|
||||
{connection.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
{(connection as any).company_name || connection.company_code}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
||||
<TableCell className="h-10 px-3 font-mono text-xs">
|
||||
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
||||
{connection.base_url}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-center text-sm">
|
||||
<TableCell className="h-10 px-3 text-center text-xs">
|
||||
{Object.keys(connection.default_headers || {}).length}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
{connection.last_test_date ? (
|
||||
<div>
|
||||
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{new Date(connection.last_test_date).toLocaleDateString()}</span>
|
||||
<Badge
|
||||
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
|
||||
className="mt-1"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{connection.last_test_result === "Y" ? "성공" : "실패"}
|
||||
</Badge>
|
||||
@@ -343,41 +343,41 @@ export function RestApiConnectionList() {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(connection)}
|
||||
disabled={testingConnections.has(connection.id!)}
|
||||
className="h-9 text-sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(connection.id!) && (
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<TableCell className="h-10 px-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditConnection(connection)}
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConnection(connection)}
|
||||
className="text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -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<string, ReferenceTableColumn[]>;
|
||||
@@ -57,6 +64,7 @@ export function ColumnGrid({
|
||||
getColumnIndexState: externalGetIndexState,
|
||||
onPkToggle,
|
||||
onIndexToggle,
|
||||
onDeleteColumn,
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
}: ColumnGridProps) {
|
||||
@@ -285,20 +293,37 @@ export function ColumnGrid({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectColumn(column.column_name);
|
||||
}}
|
||||
aria-label="상세 설정"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="컬럼 액션 메뉴"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDeleteColumn?.(column.column_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
컬럼 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -92,6 +92,11 @@ export function ResponsiveDataView<T>({
|
||||
}: ResponsiveDataViewProps<T>) {
|
||||
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<T>[] {
|
||||
if (typeof cardFields === "function") return cardFields(item);
|
||||
@@ -233,16 +238,20 @@ export function ResponsiveDataView<T>({
|
||||
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden rounded-lg border bg-card shadow-sm @3xl:block",
|
||||
// scrollContainer 모드: 부모 flex 안에서 가용 height 다 차지 + 자체 세로 스크롤 + sticky 헤더
|
||||
scrollContainer && "min-h-0 flex-1 overflow-y-auto overflow-x-auto",
|
||||
// scrollContainer 모드는 flex 컨테이너로, 아니면 block 으로 표시 (둘 다 < @3xl 에서는 hidden)
|
||||
scrollContainer
|
||||
? "hidden flex-col rounded-lg border bg-card shadow-sm @3xl:flex"
|
||||
: "hidden rounded-lg border bg-card shadow-sm @3xl:block",
|
||||
// 부모 flex 안에서 가용 height 다 차지. 실제 스크롤은 Table wrapper 가 담당
|
||||
// (Table 컴포넌트가 만드는 내부 wrapper 에 flex-1 overflow-auto 를 주면 sticky header 가 그 wrapper 기준으로 작동).
|
||||
scrollContainer && "min-h-0 flex-1 overflow-hidden",
|
||||
tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<Table divClassName={scrollContainer ? "flex-1 overflow-auto" : undefined}>
|
||||
<TableHeader
|
||||
className={cn(
|
||||
scrollContainer && "sticky top-0 z-10 bg-card"
|
||||
scrollContainer && "sticky top-0 z-10 bg-muted"
|
||||
)}
|
||||
>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
@@ -250,7 +259,7 @@ export function ResponsiveDataView<T>({
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={cn(headHeight, "text-sm font-semibold")}
|
||||
className={cn(headHeight, cellPad, headText, "font-semibold")}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
@@ -258,7 +267,7 @@ export function ResponsiveDataView<T>({
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className={cn(headHeight, "text-sm font-semibold")}
|
||||
className={cn(headHeight, cellPad, headText, "font-semibold")}
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
@@ -278,7 +287,7 @@ export function ResponsiveDataView<T>({
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(rowHeight, "text-sm", col.className)}
|
||||
className={cn(rowHeight, cellPad, bodyText, col.className)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
@@ -286,7 +295,7 @@ export function ResponsiveDataView<T>({
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className={cn(rowHeight, "text-sm")}>
|
||||
<TableCell className={cn(rowHeight, cellPad, bodyText)}>
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -319,11 +328,11 @@ export function ResponsiveDataView<T>({
|
||||
{/* 카드 헤더 */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold">
|
||||
<h3 className={cn("truncate font-semibold", cardTitleClass)}>
|
||||
{cardTitle(item)}
|
||||
</h3>
|
||||
{cardSubtitle && (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
<p className={cn("mt-0.5 truncate text-muted-foreground", cardSubText)}>
|
||||
{cardSubtitle(item)}
|
||||
</p>
|
||||
)}
|
||||
@@ -337,7 +346,7 @@ export function ResponsiveDataView<T>({
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-1.5 border-t pt-3">
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<div key={i} className={cn("flex justify-between", cardSubText)}>
|
||||
<span className="text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
|
||||
@@ -37,6 +37,14 @@ export const ddlApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 컬럼 삭제 (ALTER TABLE ... DROP COLUMN)
|
||||
*/
|
||||
dropColumn: async (tableName: string, columnName: string): Promise<DDLExecutionResult> => {
|
||||
const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user