Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m44s

This commit is contained in:
DDD1542
2026-05-19 21:31:11 +09:00
15 changed files with 514 additions and 168 deletions
+5
View File
@@ -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={}",
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,30 +297,62 @@ 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);
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 ("Y".equalsIgnoreCase(sslEnabled)) {
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);
Statement stmt = c.createStatement()) {
@@ -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 != &quot;*&quot;">
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">
<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"
onClick={(e) => {
e.stopPropagation();
onSelectColumn(column.column_name);
}}
aria-label="상세 설정"
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>
+8
View File
@@ -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;
},
/**
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
*/