fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈 #21

Merged
hjjeong merged 3 commits from hjjeong into main 2026-05-19 02:25:10 +00:00
9 changed files with 261 additions and 151 deletions
+5
View File
@@ -33,6 +33,11 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.postgresql:postgresql' 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' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-core'
@@ -100,13 +100,22 @@ public class DataCopier {
try (Statement us = dst.createStatement()) { try (Statement us = dst.createStatement()) {
for (String[] r : rows) { for (String[] r : rows) {
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3]; 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++; skippedType++;
continue; continue;
} }
String sql = String.format(
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
seq.replace("'", "''"), col, tbl);
try { try {
us.execute(sql); us.execute(sql);
updated++; updated++;
@@ -117,14 +126,8 @@ public class DataCopier {
} }
} }
} }
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때 log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상. updated, skippedType, skippedErr, rows.size());
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());
}
return updated; return updated;
} }
@@ -135,6 +138,12 @@ public class DataCopier {
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2"); || 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 { private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>(); List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement( try (PreparedStatement ps = conn.prepareStatement(
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
private Map<String, Object> executeConnectionTest( private Map<String, Object> executeConnectionTest(
String dbType, Map<String, Object> conn, String password) { String dbType, Map<String, Object> conn, String password) {
String type = dbType == null ? "" : dbType.toLowerCase();
String host = str(conn, "host"); 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 database = str(conn, "database_name");
String username = str(conn, "username"); String username = str(conn, "username");
String sslEnabled = str(conn, "ssl_enabled"); String sslEnabled = str(conn, "ssl_enabled");
int connTimeout = toInt(conn, "connection_timeout", 30); int connTimeout = toInt(conn, "connection_timeout", 30);
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
if (!"postgresql".equalsIgnoreCase(dbType)) { String url;
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);
Properties props = new Properties(); Properties props = new Properties();
props.setProperty("user", username); if (username != null) props.setProperty("user", username);
props.setProperty("password", password); if (password != null) props.setProperty("password", password);
props.setProperty("connect_timeout", String.valueOf(connTimeout));
props.setProperty("socket_timeout", "30"); switch (type) {
if ("Y".equalsIgnoreCase(sslEnabled)) { case "postgresql" -> {
props.setProperty("ssl", "true"); url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
props.setProperty("sslmode", "require"); 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); try (Connection c = DriverManager.getConnection(url, props);
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true); result.put("success", true);
result.put("message", "연결 성공"); 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; return result;
} catch (SQLException e) { } catch (SQLException e) {
log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage()); 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) ──────────────────────────────────────────── // ── SQL 쿼리 실행 (SELECT only) ────────────────────────────────────────────
public Map<String, Object> executeQuery(long id, String sql) { public Map<String, Object> executeQuery(long id, String sql) {
@@ -81,7 +81,7 @@
, E.CREATED_DATE , E.CREATED_DATE
, E.UPDATED_DATE , E.UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS E FROM EXTERNAL_DB_CONNECTIONS E
WHERE E.ID = #{id} WHERE E.ID = #{id}::varchar
</select> </select>
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) --> <!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
@@ -109,14 +109,14 @@
, CREATED_DATE , CREATED_DATE
, UPDATED_DATE , UPDATED_DATE
FROM EXTERNAL_DB_CONNECTIONS FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id} WHERE ID = #{id}::varchar
</select> </select>
<!-- 비밀번호만 조회 --> <!-- 비밀번호만 조회 -->
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map"> <select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
SELECT PASSWORD SELECT PASSWORD
FROM EXTERNAL_DB_CONNECTIONS FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id} WHERE ID = #{id}::varchar
</select> </select>
<!-- 이름+회사 중복 확인 --> <!-- 이름+회사 중복 확인 -->
@@ -134,7 +134,7 @@
FROM EXTERNAL_DB_CONNECTIONS FROM EXTERNAL_DB_CONNECTIONS
WHERE CONNECTION_NAME = #{connection_name} WHERE CONNECTION_NAME = #{connection_name}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND ID != #{exclude_id} AND ID != #{exclude_id}::varchar
LIMIT 1 LIMIT 1
</select> </select>
@@ -166,13 +166,13 @@
, #{description} , #{description}
, #{db_type} , #{db_type}
, #{host} , #{host}
, #{port} , #{port}::varchar
, #{database_name} , #{database_name}
, #{username} , #{username}
, #{password} , #{password}
, #{connection_timeout} , #{connection_timeout}::varchar
, #{query_timeout} , #{query_timeout}::varchar
, #{max_connections} , #{max_connections}::varchar
, #{ssl_enabled} , #{ssl_enabled}
, #{ssl_cert_path} , #{ssl_cert_path}
, #{connection_options}::JSONB , #{connection_options}::JSONB
@@ -193,13 +193,13 @@
<if test="description != null">DESCRIPTION = #{description},</if> <if test="description != null">DESCRIPTION = #{description},</if>
<if test="db_type != null">DB_TYPE = #{db_type},</if> <if test="db_type != null">DB_TYPE = #{db_type},</if>
<if test="host != null">HOST = #{host},</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="database_name != null">DATABASE_NAME = #{database_name},</if>
<if test="username != null">USERNAME = #{username},</if> <if test="username != null">USERNAME = #{username},</if>
<if test="password != null">PASSWORD = #{password},</if> <if test="password != null">PASSWORD = #{password},</if>
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if> <if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if> <if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</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_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</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> <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> <if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
UPDATED_DATE = NOW() UPDATED_DATE = NOW()
</set> </set>
WHERE ID = #{id} WHERE ID = #{id}::varchar
</update> </update>
<!-- 삭제 --> <!-- 삭제 -->
<delete id="deleteExternalDbConnection" parameterType="map"> <delete id="deleteExternalDbConnection" parameterType="map">
DELETE FROM EXTERNAL_DB_CONNECTIONS DELETE FROM EXTERNAL_DB_CONNECTIONS
WHERE ID = #{id} WHERE ID = #{id}::varchar
<if test="company_code != null and company_code != &quot;*&quot;"> <if test="company_code != null and company_code != &quot;*&quot;">
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if> </if>
@@ -35,6 +35,7 @@ import {
} from "@/lib/api/batch"; } from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner"; import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { Pagination } from "@/components/common/Pagination";
import { useTabStore } from "@/stores/tabStore"; import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string { function cronToKorean(cron: string): string {
@@ -331,6 +332,10 @@ export default function BatchManagementPage() {
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null); const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const loadBatchConfigs = useCallback(async () => { const loadBatchConfigs = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -364,6 +369,9 @@ export default function BatchManagementPage() {
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]); useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
// 검색/필터 변경 시 1페이지로 리셋
useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]);
const handleRowClick = async (batchId: number) => { const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; } if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId); setExpandedBatch(batchId);
@@ -443,14 +451,22 @@ export default function BatchManagementPage() {
return true; 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 activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches; const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0; const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0; const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="w-full space-y-4 px-4 py-6 sm:px-6"> <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"> <div className="flex items-center justify-between">
@@ -534,8 +550,8 @@ export default function BatchManagementPage() {
</div> </div>
</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 && ( {loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center"> <div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" /> <RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
@@ -549,7 +565,7 @@ export default function BatchManagementPage() {
</div> </div>
)} )}
{filteredBatches.map((batch) => { {pagedBatches.map((batch) => {
const batchId = batch.id!; const batchId = batch.id!;
const isExpanded = expandedBatch === batchId; const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId; const isExecuting = executingBatch === batchId;
@@ -674,6 +690,29 @@ export default function BatchManagementPage() {
})} })}
</div> </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 && ( {isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}> <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, { key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
render: (_v, row) => ( render: (_v, row) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }} onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
disabled={testingConnections.has(row.id!)} disabled={testingConnections.has(row.id!)}
className="h-9 text-sm"> className="h-7 px-2 text-xs">
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"} {testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
{testResults.has(row.id!) && ( {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!) ? "성공" : "실패"} {testResults.get(row.id!) ? "성공" : "실패"}
</Badge> </Badge>
)} )}
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
]; ];
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="space-y-6 p-6"> <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"> <div className="shrink-0 space-y-0.5 border-b pb-3">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p> <p className="text-xs text-muted-foreground"> REST API </p>
</div> </div>
{/* 탭 */} {/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList className="grid w-full max-w-[400px] grid-cols-2"> <TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2"> <TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
<Database className="h-4 w-4" /> <Database className="h-3.5 w-3.5" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-2"> <TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
<Globe className="h-4 w-4" /> <Globe className="h-3.5 w-3.5" />
REST API REST API
</TabsTrigger> </TabsTrigger>
</TabsList> </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 shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[260px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="연결명 또는 설명으로 검색..." placeholder="연결명 또는 설명으로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="h-8 pl-9 text-xs"
/> />
</div> </div>
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}> <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 타입" /> <SelectValue placeholder="DB 타입" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{supportedDbTypes.map((type) => ( {supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type.value} value={type.value} className="text-xs">
{type.label} {type.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}> <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="상태" /> <SelectValue placeholder="상태" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => ( {ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value} className="text-xs">
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
isLoading={loading} isLoading={loading}
emptyMessage="등록된 연결이 없습니다" emptyMessage="등록된 연결이 없습니다"
skeletonCount={5} skeletonCount={5}
compact
scrollContainer
cardTitle={(c) => c.connection_name} cardTitle={(c) => c.connection_name}
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>} cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
cardHeaderRight={(c) => ( 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" ? "활성" : "비활성"} {c.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
)} )}
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }} onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
disabled={testingConnections.has(c.id!)} 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!) ? "테스트 중..." : "테스트"} {testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
setSelectedConnection(c); setSelectedConnection(c);
setSqlModalOpen(true); setSqlModalOpen(true);
}} }}
className="h-9 flex-1 gap-2 text-sm"> className="h-7 flex-1 gap-1 text-xs">
<Terminal className="h-4 w-4" /> <Terminal className="h-3.5 w-3.5" />
SQL SQL
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }} onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
className="h-9 flex-1 gap-2 text-sm"> className="h-7 flex-1 gap-1 text-xs">
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }} onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm"> className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
</TabsContent> </TabsContent>
{/* REST API 연결 탭 */} {/* 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 /> <RestApiConnectionList />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl"> <DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle className="text-base sm:text-lg"> <DialogTitle className="text-base sm:text-lg">
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
</DialogTitle> </DialogTitle>
</DialogHeader> </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"> <div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3> <h3 className="text-sm font-semibold sm:text-base"> </h3>
@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="shrink-0 gap-2 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}
@@ -219,27 +219,27 @@ export function RestApiConnectionList() {
return ( return (
<> <>
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex shrink-0 flex-col gap-3 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 flex-col gap-2 sm:flex-row sm:items-center">
{/* 검색 */} {/* 검색 */}
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[260px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
<Input <Input
placeholder="연결명 또는 URL로 검색..." placeholder="연결명 또는 URL로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="h-8 pl-9 text-xs"
/> />
</div> </div>
{/* 인증 타입 필터 */} {/* 인증 타입 필터 */}
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}> <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="인증 타입" /> <SelectValue placeholder="인증 타입" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{supportedAuthTypes.map((type) => ( {supportedAuthTypes.map((type) => (
<SelectItem key={type.value} value={type.value}> <SelectItem key={type.value} value={type.value} className="text-xs">
{type.label} {type.label}
</SelectItem> </SelectItem>
))} ))}
@@ -248,12 +248,12 @@ export function RestApiConnectionList() {
{/* 활성 상태 필터 */} {/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}> <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="상태" /> <SelectValue placeholder="상태" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => ( {ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value} className="text-xs">
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
@@ -262,79 +262,79 @@ export function RestApiConnectionList() {
</div> </div>
{/* 추가 버튼 */} {/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
{/* 연결 목록 */} {/* 연결 목록 */}
{loading ? ( {loading ? (
<div className="flex h-64 items-center justify-center bg-card"> <div className="flex h-40 shrink-0 items-center justify-center rounded-lg border bg-card">
<div className="text-sm text-muted-foreground"> ...</div> <div className="text-xs text-muted-foreground"> ...</div>
</div> </div>
) : connections.length === 0 ? ( ) : 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"> <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> </div>
) : ( ) : (
<div className="bg-card"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<Table> <Table divClassName="flex-1 overflow-auto">
<TableHeader> <TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow className="bg-background"> <TableRow className="border-b bg-muted hover:bg-muted">
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> URL</TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> URL</TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-right text-xs font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{connections.map((connection) => ( {connections.map((connection) => (
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50"> <TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<div className="max-w-[200px]"> <div className="max-w-[200px]">
<div className="truncate font-medium" title={connection.connection_name}> <div className="truncate font-medium" title={connection.connection_name}>
{connection.connection_name} {connection.connection_name}
</div> </div>
{connection.description && ( {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} {connection.description}
</div> </div>
)} )}
</div> </div>
</TableCell> </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} {(connection as any).company_name || connection.company_code}
</TableCell> </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}> <div className="max-w-[300px] truncate" title={connection.base_url}>
{connection.base_url} {connection.base_url}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge> <Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
</TableCell> </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} {Object.keys(connection.default_headers || {}).length}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}> <Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{connection.is_active === "Y" ? "활성" : "비활성"} {connection.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
{connection.last_test_date ? ( {connection.last_test_date ? (
<div> <div className="flex items-center gap-1.5">
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div> <span>{new Date(connection.last_test_date).toLocaleDateString()}</span>
<Badge <Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"} variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1" className="text-[10px]"
> >
{connection.last_test_result === "Y" ? "성공" : "실패"} {connection.last_test_result === "Y" ? "성공" : "실패"}
</Badge> </Badge>
@@ -343,41 +343,41 @@ export function RestApiConnectionList() {
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-10 px-3 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleTestConnection(connection)} onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)} disabled={testingConnections.has(connection.id!)}
className="h-9 text-sm" className="h-7 px-2 text-xs"
> >
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"} {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
{testResults.has(connection.id!) && ( {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!) ? "성공" : "실패"} {testResults.get(connection.id!) ? "성공" : "실패"}
</Badge> </Badge>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-right"> <TableCell className="h-10 px-3 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleEditConnection(connection)} 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>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteConnection(connection)} 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> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -92,6 +92,11 @@ export function ResponsiveDataView<T>({
}: ResponsiveDataViewProps<T>) { }: ResponsiveDataViewProps<T>) {
const rowHeight = compact ? "h-10" : "h-16"; const rowHeight = compact ? "h-10" : "h-16";
const headHeight = compact ? "h-9" : "h-12"; 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에서 자동 생성 // cardFields 미지정 시 columns에서 자동 생성
function resolveCardFields(item: T): RDVCardField<T>[] { function resolveCardFields(item: T): RDVCardField<T>[] {
if (typeof cardFields === "function") return cardFields(item); if (typeof cardFields === "function") return cardFields(item);
@@ -233,16 +238,20 @@ export function ResponsiveDataView<T>({
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */} {/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
<div <div
className={cn( className={cn(
"hidden rounded-lg border bg-card shadow-sm @3xl:block", // scrollContainer 모드는 flex 컨테이너로, 아니면 block 으로 표시 (둘 다 < @3xl 에서는 hidden)
// scrollContainer 모드: 부모 flex 안에서 가용 height 다 차지 + 자체 세로 스크롤 + sticky 헤더 scrollContainer
scrollContainer && "min-h-0 flex-1 overflow-y-auto overflow-x-auto", ? "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 tableContainerClassName
)} )}
> >
<Table> <Table divClassName={scrollContainer ? "flex-1 overflow-auto" : undefined}>
<TableHeader <TableHeader
className={cn( 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"> <TableRow className="border-b bg-muted/50 hover:bg-muted/50">
@@ -250,7 +259,7 @@ export function ResponsiveDataView<T>({
<TableHead <TableHead
key={col.key} key={col.key}
style={col.width ? { width: col.width } : undefined} style={col.width ? { width: col.width } : undefined}
className={cn(headHeight, "text-sm font-semibold")} className={cn(headHeight, cellPad, headText, "font-semibold")}
> >
{col.label} {col.label}
</TableHead> </TableHead>
@@ -258,7 +267,7 @@ export function ResponsiveDataView<T>({
{renderActions && ( {renderActions && (
<TableHead <TableHead
style={{ width: actionsWidth || "120px" }} style={{ width: actionsWidth || "120px" }}
className={cn(headHeight, "text-sm font-semibold")} className={cn(headHeight, cellPad, headText, "font-semibold")}
> >
{actionsLabel || "작업"} {actionsLabel || "작업"}
</TableHead> </TableHead>
@@ -278,7 +287,7 @@ export function ResponsiveDataView<T>({
{columns.map((col) => ( {columns.map((col) => (
<TableCell <TableCell
key={col.key} key={col.key}
className={cn(rowHeight, "text-sm", col.className)} className={cn(rowHeight, cellPad, bodyText, col.className)}
> >
{col.render {col.render
? col.render(getNestedValue(item, col.key), item, index) ? col.render(getNestedValue(item, col.key), item, index)
@@ -286,7 +295,7 @@ export function ResponsiveDataView<T>({
</TableCell> </TableCell>
))} ))}
{renderActions && ( {renderActions && (
<TableCell className={cn(rowHeight, "text-sm")}> <TableCell className={cn(rowHeight, cellPad, bodyText)}>
<div className="flex gap-2">{renderActions(item)}</div> <div className="flex gap-2">{renderActions(item)}</div>
</TableCell> </TableCell>
)} )}
@@ -319,11 +328,11 @@ export function ResponsiveDataView<T>({
{/* 카드 헤더 */} {/* 카드 헤더 */}
<div className="mb-3 flex items-start justify-between"> <div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold"> <h3 className={cn("truncate font-semibold", cardTitleClass)}>
{cardTitle(item)} {cardTitle(item)}
</h3> </h3>
{cardSubtitle && ( {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)} {cardSubtitle(item)}
</p> </p>
)} )}
@@ -337,7 +346,7 @@ export function ResponsiveDataView<T>({
{fields.length > 0 && ( {fields.length > 0 && (
<div className="space-y-1.5 border-t pt-3"> <div className="space-y-1.5 border-t pt-3">
{fields.map((field, i) => ( {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"> <span className="text-muted-foreground">
{field.label} {field.label}
</span> </span>